mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-14 08:36:07 +00:00
Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a9d8905393 | |||
| ca2fd3814f | |||
| 394fafd29b | |||
| 9f0055d193 | |||
| 88dafa6cdf | |||
| 2b3b2770b4 | |||
| 094ea9faaa | |||
| eb16cca551 | |||
| aef0503f8f | |||
| 86304226a9 | |||
| 6d0e7ec495 | |||
| 04b4fb4384 | |||
| ae5ad505d0 | |||
| c188056f64 | |||
| 0a424a9f16 | |||
| 36b2a58675 | |||
| 59e9859225 | |||
| 4e5f144def | |||
| ea53da9341 | |||
| 3e1a102e9d | |||
| d580086361 | |||
| 1e3b366fba | |||
| c2a2a36f56 | |||
| e986a133f8 | |||
| 7466916e02 | |||
| 96b568b838 | |||
| fec714a243 | |||
| e02f78ac68 | |||
| 169b216d7e | |||
| 7e6d80efa5 | |||
| 38d87230d3 | |||
| f0f98c7122 | |||
| 554981d9d3 | |||
| a08f1c6c13 | |||
| 7f7ae0c547 | |||
| 60abeaad66 | |||
| a6d0100381 | |||
| 6767f76ccf | |||
| e4add73c9e | |||
| ff72090e1a | |||
| a3e1bd59df | |||
| 5bbb48a8fd | |||
| ee84d585f9 | |||
| 7b03346cfc | |||
| 258b08fff3 | |||
| a2097ad062 |
@@ -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"
|
||||
@@ -0,0 +1,45 @@
|
||||
name: "CodeQL Advanced"
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: '18 2 * * 2'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
|
||||
env:
|
||||
CODEQL_ACTION_FILE_COVERAGE_ON_PRS: true
|
||||
permissions:
|
||||
security-events: write
|
||||
packages: read
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- language: actions
|
||||
build-mode: none
|
||||
- language: go
|
||||
build-mode: autobuild
|
||||
- language: javascript-typescript
|
||||
build-mode: none
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
@@ -15,13 +15,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
hsanaeii/3x-ui
|
||||
@@ -32,28 +32,28 @@ jobs:
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
with:
|
||||
install: true
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
||||
@@ -2,27 +2,64 @@ name: Release 3X-UI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
release:
|
||||
types: [published]
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "**"
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
paths:
|
||||
- '**.js'
|
||||
- '**.css'
|
||||
- '**.html'
|
||||
- '**.sh'
|
||||
- '**.go'
|
||||
- 'go.mod'
|
||||
- 'go.sum'
|
||||
- 'x-ui.service.debian'
|
||||
- 'x-ui.service.arch'
|
||||
- 'x-ui.service.rhel'
|
||||
- "**.js"
|
||||
- "**.css"
|
||||
- "**.html"
|
||||
- "**.sh"
|
||||
- "**.go"
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
- "x-ui.service.debian"
|
||||
- "x-ui.service.arch"
|
||||
- "x-ui.service.rhel"
|
||||
pull_request:
|
||||
|
||||
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:
|
||||
needs: analyze
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
@@ -38,7 +75,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
@@ -77,7 +114,7 @@ jobs:
|
||||
go build -ldflags "-w -s -linkmode external -extldflags '-static'" -o xui-release -v main.go
|
||||
file xui-release
|
||||
ldd xui-release || echo "Static binary confirmed"
|
||||
|
||||
|
||||
mkdir x-ui
|
||||
cp xui-release x-ui/
|
||||
cp x-ui.service.debian x-ui/
|
||||
@@ -87,9 +124,9 @@ jobs:
|
||||
mv x-ui/xui-release x-ui/x-ui
|
||||
mkdir x-ui/bin
|
||||
cd x-ui/bin
|
||||
|
||||
|
||||
# Download dependencies
|
||||
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.2.6/"
|
||||
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.4.17/"
|
||||
if [ "${{ matrix.platform }}" == "amd64" ]; then
|
||||
wget -q ${Xray_URL}Xray-linux-64.zip
|
||||
unzip Xray-linux-64.zip
|
||||
@@ -128,24 +165,22 @@ jobs:
|
||||
wget -q -O geosite_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat
|
||||
mv xray xray-linux-${{ matrix.platform }}
|
||||
cd ../..
|
||||
|
||||
|
||||
- name: Package
|
||||
run: tar -zcvf x-ui-linux-${{ matrix.platform }}.tar.gz x-ui
|
||||
|
||||
- name: Upload files to Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: x-ui-linux-${{ matrix.platform }}
|
||||
path: ./x-ui-linux-${{ matrix.platform }}.tar.gz
|
||||
|
||||
- name: Upload files to GH release
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
if: |
|
||||
(github.event_name == 'release' && github.event.action == 'published') ||
|
||||
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/'))
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
tag: ${{ github.ref }}
|
||||
tag: ${{ github.ref_name }}
|
||||
file: x-ui-linux-${{ matrix.platform }}.tar.gz
|
||||
asset_name: x-ui-linux-${{ matrix.platform }}.tar.gz
|
||||
overwrite: true
|
||||
@@ -156,6 +191,7 @@ jobs:
|
||||
# =================================
|
||||
build-windows:
|
||||
name: Build for Windows
|
||||
needs: analyze
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
@@ -165,7 +201,7 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
@@ -206,9 +242,9 @@ jobs:
|
||||
Copy-Item xui-release.exe x-ui\x-ui.exe
|
||||
mkdir x-ui\bin
|
||||
cd x-ui\bin
|
||||
|
||||
|
||||
# Download Xray for Windows
|
||||
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.2.6/"
|
||||
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.4.17/"
|
||||
Invoke-WebRequest -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
|
||||
Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
|
||||
Remove-Item "Xray-windows-64.zip"
|
||||
@@ -230,19 +266,17 @@ jobs:
|
||||
Compress-Archive -Path .\x-ui -DestinationPath "x-ui-windows-amd64.zip"
|
||||
|
||||
- name: Upload files to Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: x-ui-windows-amd64
|
||||
path: ./x-ui-windows-amd64.zip
|
||||
|
||||
- name: Upload files to GH release
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
if: |
|
||||
(github.event_name == 'release' && github.event.action == 'published') ||
|
||||
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/'))
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
tag: ${{ github.ref }}
|
||||
tag: ${{ github.ref_name }}
|
||||
file: x-ui-windows-amd64.zip
|
||||
asset_name: x-ui-windows-amd64.zip
|
||||
overwrite: true
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ case $1 in
|
||||
esac
|
||||
mkdir -p build/bin
|
||||
cd build/bin
|
||||
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.2.6/Xray-linux-${ARCH}.zip"
|
||||
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.4.17/Xray-linux-${ARCH}.zip"
|
||||
unzip "Xray-linux-${ARCH}.zip"
|
||||
rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
|
||||
mv xray "xray-linux-${FNAME}"
|
||||
|
||||
@@ -22,6 +22,14 @@
|
||||
|
||||
كمشروع محسن من مشروع 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.
|
||||
|
||||
## 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
|
||||
|
||||
```
|
||||
|
||||
@@ -22,6 +22,14 @@
|
||||
|
||||
به عنوان یک نسخه بهبود یافته از پروژه اصلی 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.
|
||||
|
||||
## 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
|
||||
|
||||
```bash
|
||||
|
||||
@@ -22,6 +22,14 @@
|
||||
|
||||
Как улучшенная версия оригинального проекта 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 提供了更好的稳定性、更广泛的协议支持和额外的功能。
|
||||
|
||||
## 自定义 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
@@ -1 +1 @@
|
||||
2.8.11
|
||||
2.9.0
|
||||
+2
-2
@@ -38,6 +38,7 @@ func initModels() error {
|
||||
&model.InboundClientIps{},
|
||||
&xray.ClientTraffic{},
|
||||
&model.HistoryOfSeeders{},
|
||||
&model.CustomGeoResource{},
|
||||
}
|
||||
for _, model := range models {
|
||||
if err := db.AutoMigrate(model); err != nil {
|
||||
@@ -175,9 +176,8 @@ func GetDB() *gorm.DB {
|
||||
return db
|
||||
}
|
||||
|
||||
// IsNotFound checks if the given error is a GORM record not found error.
|
||||
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.
|
||||
|
||||
+17
-3
@@ -21,6 +21,7 @@ const (
|
||||
Shadowsocks Protocol = "shadowsocks"
|
||||
Mixed Protocol = "mixed"
|
||||
WireGuard Protocol = "wireguard"
|
||||
Hysteria Protocol = "hysteria"
|
||||
)
|
||||
|
||||
// User represents a user account in the 3x-ui panel.
|
||||
@@ -104,12 +105,25 @@ type Setting struct {
|
||||
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.
|
||||
type Client struct {
|
||||
ID string `json:"id"` // Unique client identifier
|
||||
ID string `json:"id,omitempty"` // Unique client identifier
|
||||
Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm")
|
||||
Password string `json:"password"` // Client password
|
||||
Flow string `json:"flow"` // Flow control (XTLS)
|
||||
Password string `json:"password,omitempty"` // Client password
|
||||
Flow string `json:"flow,omitempty"` // Flow control (XTLS)
|
||||
Auth string `json:"auth,omitempty"` // Auth password (Hysteria)
|
||||
Email string `json:"email"` // Client email identifier
|
||||
LimitIP int `json:"limitIp"` // IP limit for this client
|
||||
TotalGB int64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB
|
||||
|
||||
+1
-1
@@ -13,4 +13,4 @@ services:
|
||||
XUI_ENABLE_FAIL2BAN: "true"
|
||||
tty: true
|
||||
network_mode: host
|
||||
restart: unless-stopped
|
||||
restart: unless-stopped
|
||||
@@ -1,53 +1,54 @@
|
||||
module github.com/mhsanaei/3x-ui/v2
|
||||
|
||||
go 1.26.0
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
github.com/gin-contrib/gzip v1.2.5
|
||||
github.com/gin-contrib/sessions v1.0.4
|
||||
github.com/gin-contrib/gzip v1.2.6
|
||||
github.com/gin-contrib/sessions v1.1.0
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/go-ldap/ldap/v3 v3.4.12
|
||||
github.com/goccy/go-json v0.10.5
|
||||
github.com/go-ldap/ldap/v3 v3.4.13
|
||||
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/gorilla/websocket v1.5.3
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/mymmrac/telego v1.7.0
|
||||
github.com/mymmrac/telego v1.8.0
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1
|
||||
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/shirou/gopsutil/v4 v4.26.2
|
||||
github.com/shirou/gopsutil/v4 v4.26.3
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/valyala/fasthttp v1.69.0
|
||||
github.com/valyala/fasthttp v1.70.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
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/sys v0.41.0
|
||||
golang.org/x/text v0.34.0
|
||||
google.golang.org/grpc v1.79.1
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/sys v0.43.0
|
||||
golang.org/x/text v0.36.0
|
||||
google.golang.org/grpc v1.80.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Azure/go-ntlmssp v0.1.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/apernet/quic-go v0.57.2-0.20260111184307-eec823306178 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||
github.com/apernet/quic-go v0.59.1-0.20260217092621-db4786c77a22 // indirect
|
||||
github.com/bytedance/gopkg v0.1.4 // 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/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/ebitengine/purego v0.10.0 // 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-ole/go-ole v1.3.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.2 // indirect
|
||||
github.com/google/btree v1.1.3 // indirect
|
||||
github.com/gorilla/context v1.1.2 // indirect
|
||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
@@ -57,22 +58,22 @@ require (
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // 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/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.34 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
|
||||
github.com/mattn/go-isatty v0.0.21 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.42 // indirect
|
||||
github.com/miekg/dns v1.1.72 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pires/go-proxyproto v0.11.0 // indirect
|
||||
github.com/pires/go-proxyproto v0.12.0 // 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/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/sagernet/sing v0.8.1 // indirect
|
||||
github.com/sagernet/sing v0.8.8 // indirect
|
||||
github.com/sagernet/sing-shadowsocks v0.2.9 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||
@@ -82,21 +83,20 @@ require (
|
||||
github.com/valyala/fastjson v1.6.10 // indirect
|
||||
github.com/vishvananda/netlink v1.3.1 // 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
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.1 // indirect
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
||||
golang.org/x/arch v0.24.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
golang.org/x/arch v0.26.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
|
||||
golang.org/x/mod v0.35.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
golang.org/x/tools v0.44.0 // indirect
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
|
||||
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
|
||||
lukechampine.com/blake3 v1.4.1 // indirect
|
||||
)
|
||||
|
||||
@@ -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/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/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/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.57.2-0.20260111184307-eec823306178/go.mod h1:N1WIjPphkqs4efXWuyDNQ6OjjIK04vM3h+bEgwV+eVU=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/apernet/quic-go v0.59.1-0.20260217092621-db4786c77a22 h1:00ziBGnLWQEcR9LThDwvxOznJJquJ9bYUdmBFnawLMU=
|
||||
github.com/apernet/quic-go v0.59.1-0.20260217092621-db4786c77a22/go.mod h1:Npbg8qBtAZlsAB3FWmqwlVh5jtVG6a4DlYsOylUpvzA=
|
||||
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
|
||||
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/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
|
||||
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/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||
@@ -29,18 +29,18 @@ github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9
|
||||
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/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.5/go.mod h1:aomRgR7ftdZV3uWY0gW/m8rChfxau0n8YVvwlOHONzw=
|
||||
github.com/gin-contrib/sessions v1.0.4 h1:ha6CNdpYiTOK/hTp05miJLbpTSNfOnFg5Jm2kbcqy8U=
|
||||
github.com/gin-contrib/sessions v1.0.4/go.mod h1:ccmkrb2z6iU2osiAHZG3x3J4suJK+OU27oqzlWOqQgs=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-contrib/gzip v1.2.6 h1:OtN8DplD5DNZCSLAnQ5HxRkD2qZ5VU+JhOrcfJrcRvg=
|
||||
github.com/gin-contrib/gzip v1.2.6/go.mod h1:BQy8/+JApnRjAVUplSGZiVtD2k8GmIE2e9rYu/hLzzU=
|
||||
github.com/gin-contrib/sessions v1.1.0 h1:00mhHfNEGF5sP2fwxa98aRqj1FOJdL6IkR86n2hOiBo=
|
||||
github.com/gin-contrib/sessions v1.1.0/go.mod h1:TyYZDIs6qCQg2SOoYPgMT9pAkmZceVNEJMcv5qbIy60=
|
||||
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
|
||||
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
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/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.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
|
||||
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
|
||||
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/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
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/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/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
|
||||
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
|
||||
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||
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/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
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/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/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
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/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
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/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/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 h1:PTw+yKnXcOFCR6+8hHTyWBeQ/P4Nb7dd4/0ohEcWQuM=
|
||||
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88/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/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.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
|
||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
||||
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo=
|
||||
github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
||||
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/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -130,18 +130,18 @@ 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/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/mymmrac/telego v1.7.0 h1:yRO/l00tFGG4nY66ufUKb4ARqv7qx9+LsjQv/b0NEyo=
|
||||
github.com/mymmrac/telego v1.7.0/go.mod h1:pdLV346EgVuq7Xrh3kMggeBiazeHhsdEoK0RTEOPXRM=
|
||||
github.com/mymmrac/telego v1.8.0 h1:EvIprWo9Cn0MHgumvvqNXPAXO1yJj3pu2cdCCeDxbow=
|
||||
github.com/mymmrac/telego v1.8.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/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/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
|
||||
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/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/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/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pires/go-proxyproto v0.12.0 h1:TTCxD66dU898tahivkqc3hoceZp7P44FnorWyo9d5vM=
|
||||
github.com/pires/go-proxyproto v0.12.0/go.mod h1:qUvfqUMEoX7T8g0q7TQLDnhMjdTrxnG0hvpMn+7ePNI=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
@@ -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/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
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.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af h1:er2acxbi3N1nvEq6HXHUAR1nTWEJmQfqiGR8EVT9rfs=
|
||||
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/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/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/sagernet/sing v0.8.1 h1:Li+zg4xdiMsvdX4j50TPqmSG8LF/TB9US2qlAN40izU=
|
||||
github.com/sagernet/sing v0.8.1/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak=
|
||||
github.com/sagernet/sing v0.8.8 h1:1dRlGJ3wm4d2nwjKI1R/dr/7GKDKgUvXyD4OAWlQyt8=
|
||||
github.com/sagernet/sing v0.8.8/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/go.mod h1:TE/Z6401Pi8tgr0nBZcM/xawAI6u3F6TTbz4nH/qw+8=
|
||||
github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI=
|
||||
github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||
github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
|
||||
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/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
@@ -185,8 +185,8 @@ github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
|
||||
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
|
||||
github.com/valyala/fasthttp v1.70.0 h1:LAhMGcWk13QZWm85+eg8ZBNbrq5mnkWFGbHMUJHIdXA=
|
||||
github.com/valyala/fasthttp v1.70.0/go.mod h1:oDZEHHkJ/Buyklg6uURmYs19442zFSnCIfX3j1FY3pE=
|
||||
github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
|
||||
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
|
||||
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
|
||||
@@ -195,16 +195,16 @@ github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zd
|
||||
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/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-20251116175510-cd53f7d50237/go.mod h1:vbHCV/3VWUvy1oKvTxxWJRPEWSeR1sYgQHIh6u/JiZQ=
|
||||
github.com/xtls/xray-core v1.260206.0 h1:gY8IV6u76CW93txL9QmacgZ0Udxr2Q3e9qUxXAhdHqI=
|
||||
github.com/xtls/xray-core v1.260206.0/go.mod h1:GyFIgVGRJkt3eyV/NMcdxOKXcJPqGGpyupHzy16uJhU=
|
||||
github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f h1:iy2JRioxmUpoJ3SzbFPyTxHZMbR/rSHP7dOOgYaq1O8=
|
||||
github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f/go.mod h1:DsJblcWDGt76+FVqBVwbwRhxyyNJsGV48gJLch0OOWI=
|
||||
github.com/xtls/xray-core v1.260327.0 h1:g4TzxMwyPrxslZh6uD+FiG3lXKTrnNO+b4ky2OhogHE=
|
||||
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/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
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.mongodb.org/mongo-driver/v2 v2.5.1 h1:j2U/Qp+wvueSpqitLCSZPT/+ZpVc1xzuwdHWwl7d8ro=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.1/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
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/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||
@@ -225,42 +225,41 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
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/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
||||
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
|
||||
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/arch v0.26.0 h1:jZ6dpec5haP/fUv1kLCbuJy6dnRrfX6iVK08lZBFpk4=
|
||||
golang.org/x/arch v0.26.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
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-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
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.6.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.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
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/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
|
||||
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.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||
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/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
+63
-41
@@ -76,37 +76,38 @@ is_port_in_use() {
|
||||
install_base() {
|
||||
case "${release}" in
|
||||
ubuntu | debian | armbian)
|
||||
apt-get update && apt-get install -y -q cron 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)
|
||||
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)
|
||||
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
|
||||
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
|
||||
;;
|
||||
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)
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
gen_random_string() {
|
||||
local length="$1"
|
||||
local random_string=$(LC_ALL=C tr -dc 'a-zA-Z0-9' </dev/urandom | fold -w "$length" | head -n 1)
|
||||
echo "$random_string"
|
||||
openssl rand -base64 $(( length * 2 )) \
|
||||
| tr -dc 'a-zA-Z0-9' \
|
||||
| head -c "$length"
|
||||
}
|
||||
|
||||
install_acme() {
|
||||
@@ -378,15 +379,15 @@ ssl_cert_issue() {
|
||||
break
|
||||
done
|
||||
echo -e "${green}Your domain is: ${domain}, checking it...${plain}"
|
||||
SSL_ISSUED_DOMAIN="${domain}"
|
||||
|
||||
# check if there already exists a certificate
|
||||
local currentCert=$(~/.acme.sh/acme.sh --list | tail -1 | awk '{print $1}')
|
||||
if [ "${currentCert}" == "${domain}" ]; then
|
||||
local certInfo=$(~/.acme.sh/acme.sh --list)
|
||||
echo -e "${red}System already has certificates for this domain. Cannot issue again.${plain}"
|
||||
echo -e "${yellow}Current certificate details:${plain}"
|
||||
echo "$certInfo"
|
||||
return 1
|
||||
# detect existing certificate and reuse it if present
|
||||
local cert_exists=0
|
||||
if ~/.acme.sh/acme.sh --list 2>/dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
|
||||
cert_exists=1
|
||||
local certInfo=$(~/.acme.sh/acme.sh --list 2>/dev/null | grep -F "${domain}")
|
||||
echo -e "${yellow}Existing certificate found for ${domain}, will reuse it.${plain}"
|
||||
[[ -n "${certInfo}" ]] && echo "$certInfo"
|
||||
else
|
||||
echo -e "${green}Your domain is ready for issuing certificates now...${plain}"
|
||||
fi
|
||||
@@ -413,16 +414,20 @@ ssl_cert_issue() {
|
||||
echo -e "${yellow}Stopping panel temporarily...${plain}"
|
||||
systemctl stop x-ui 2>/dev/null || rc-service x-ui stop 2>/dev/null
|
||||
|
||||
# issue the certificate
|
||||
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force
|
||||
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${red}Issuing certificate failed, please check logs.${plain}"
|
||||
rm -rf ~/.acme.sh/${domain}
|
||||
systemctl start x-ui 2>/dev/null || rc-service x-ui start 2>/dev/null
|
||||
return 1
|
||||
if [[ ${cert_exists} -eq 0 ]]; then
|
||||
# issue the certificate
|
||||
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force
|
||||
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${red}Issuing certificate failed, please check logs.${plain}"
|
||||
rm -rf ~/.acme.sh/${domain}
|
||||
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
|
||||
echo -e "${green}Issuing certificate succeeded, installing certificates...${plain}"
|
||||
echo -e "${green}Using existing certificate, installing certificates...${plain}"
|
||||
fi
|
||||
|
||||
# Setup reload command
|
||||
@@ -452,17 +457,27 @@ ssl_cert_issue() {
|
||||
fi
|
||||
|
||||
# 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 \
|
||||
--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}"
|
||||
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
|
||||
return 1
|
||||
else
|
||||
echo -e "${green}Installing certificate succeeded, enabling auto renew...${plain}"
|
||||
fi
|
||||
|
||||
# enable auto-renew
|
||||
@@ -535,14 +550,21 @@ prompt_and_setup_ssl() {
|
||||
1)
|
||||
# User chose Let's Encrypt domain option
|
||||
echo -e "${green}Using Let's Encrypt for domain certificate...${plain}"
|
||||
ssl_cert_issue
|
||||
# Extract the domain that was used from the certificate
|
||||
local cert_domain=$(~/.acme.sh/acme.sh --list 2>/dev/null | tail -1 | awk '{print $1}')
|
||||
if [[ -n "${cert_domain}" ]]; then
|
||||
SSL_HOST="${cert_domain}"
|
||||
echo -e "${green}✓ SSL certificate configured successfully with domain: ${cert_domain}${plain}"
|
||||
if ssl_cert_issue; then
|
||||
local cert_domain="${SSL_ISSUED_DOMAIN}"
|
||||
if [[ -z "${cert_domain}" ]]; then
|
||||
cert_domain=$(~/.acme.sh/acme.sh --list 2>/dev/null | tail -1 | awk '{print $1}')
|
||||
fi
|
||||
|
||||
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
|
||||
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}"
|
||||
fi
|
||||
;;
|
||||
@@ -580,7 +602,7 @@ prompt_and_setup_ssl() {
|
||||
|
||||
# 3.1 Request Domain to compose Panel URL later
|
||||
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
|
||||
while true; do
|
||||
|
||||
+11
-2
@@ -91,12 +91,21 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||
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()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
subClashEnable, err := s.settingService.GetSubClashEnable()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Set base_path based on LinksPath for template rendering
|
||||
// Ensure LinksPath ends with "/" for proper asset URL generation
|
||||
basePath := LinksPath
|
||||
@@ -255,7 +264,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||
g := engine.Group("/")
|
||||
|
||||
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,
|
||||
SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+38
-7
@@ -21,12 +21,15 @@ type SUBController struct {
|
||||
subRoutingRules string
|
||||
subPath string
|
||||
subJsonPath string
|
||||
subClashPath string
|
||||
jsonEnabled bool
|
||||
clashEnabled bool
|
||||
subEncrypt bool
|
||||
updateInterval string
|
||||
|
||||
subService *SubService
|
||||
subJsonService *SubJsonService
|
||||
subService *SubService
|
||||
subJsonService *SubJsonService
|
||||
subClashService *SubClashService
|
||||
}
|
||||
|
||||
// NewSUBController creates a new subscription controller with the given configuration.
|
||||
@@ -34,7 +37,9 @@ func NewSUBController(
|
||||
g *gin.RouterGroup,
|
||||
subPath string,
|
||||
jsonPath string,
|
||||
clashPath string,
|
||||
jsonEnabled bool,
|
||||
clashEnabled bool,
|
||||
encrypt bool,
|
||||
showInfo bool,
|
||||
rModel string,
|
||||
@@ -60,12 +65,15 @@ func NewSUBController(
|
||||
subRoutingRules: subRoutingRules,
|
||||
subPath: subPath,
|
||||
subJsonPath: jsonPath,
|
||||
subClashPath: clashPath,
|
||||
jsonEnabled: jsonEnabled,
|
||||
clashEnabled: clashEnabled,
|
||||
subEncrypt: encrypt,
|
||||
updateInterval: update,
|
||||
|
||||
subService: sub,
|
||||
subJsonService: NewSubJsonService(jsonFragment, jsonNoise, jsonMux, jsonRules, sub),
|
||||
subService: sub,
|
||||
subJsonService: NewSubJsonService(jsonFragment, jsonNoise, jsonMux, jsonRules, sub),
|
||||
subClashService: NewSubClashService(sub),
|
||||
}
|
||||
a.initRouter(g)
|
||||
return a
|
||||
@@ -80,6 +88,10 @@ func (a *SUBController) initRouter(g *gin.RouterGroup) {
|
||||
gJson := g.Group(a.subJsonPath)
|
||||
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.
|
||||
@@ -99,10 +111,13 @@ func (a *SUBController) subs(c *gin.Context) {
|
||||
accept := c.GetHeader("Accept")
|
||||
if strings.Contains(strings.ToLower(accept), "text/html") || c.Query("html") == "1" || strings.EqualFold(c.Query("view"), "html") {
|
||||
// Build page data in service
|
||||
subURL, subJsonURL := a.subService.BuildURLs(scheme, hostWithPort, a.subPath, a.subJsonPath, subId)
|
||||
subURL, subJsonURL, subClashURL := a.subService.BuildURLs(scheme, hostWithPort, a.subPath, a.subJsonPath, a.subClashPath, subId)
|
||||
if !a.jsonEnabled {
|
||||
subJsonURL = ""
|
||||
}
|
||||
if !a.clashEnabled {
|
||||
subClashURL = ""
|
||||
}
|
||||
// Get base_path from context (set by middleware)
|
||||
basePath, exists := c.Get("base_path")
|
||||
if !exists {
|
||||
@@ -116,7 +131,7 @@ func (a *SUBController) subs(c *gin.Context) {
|
||||
// Remove trailing slash if exists, add subId, then add trailing slash
|
||||
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{
|
||||
"title": "subscription.title",
|
||||
"cur_ver": config.GetVersion(),
|
||||
@@ -136,6 +151,7 @@ func (a *SUBController) subs(c *gin.Context) {
|
||||
"totalByte": page.TotalByte,
|
||||
"subUrl": page.SubUrl,
|
||||
"subJsonUrl": page.SubJsonUrl,
|
||||
"subClashUrl": page.SubClashUrl,
|
||||
"result": page.Result,
|
||||
})
|
||||
return
|
||||
@@ -165,7 +181,6 @@ func (a *SUBController) subJsons(c *gin.Context) {
|
||||
if err != nil || len(jsonSub) == 0 {
|
||||
c.String(400, "Error!")
|
||||
} else {
|
||||
// Add headers
|
||||
profileUrl := a.subProfileUrl
|
||||
if profileUrl == "" {
|
||||
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.
|
||||
func (a *SUBController) ApplyCommonHeaders(
|
||||
c *gin.Context,
|
||||
|
||||
+63
-10
@@ -194,6 +194,8 @@ func (s *SubJsonService) getConfig(inbound *model.Inbound, client model.Client,
|
||||
newOutbounds = append(newOutbounds, s.genVless(inbound, streamSettings, client))
|
||||
case "trojan", "shadowsocks":
|
||||
newOutbounds = append(newOutbounds, s.genServer(inbound, streamSettings, client))
|
||||
case "hysteria":
|
||||
newOutbounds = append(newOutbounds, s.genHy(inbound, newStream, client))
|
||||
}
|
||||
|
||||
newOutbounds = append(newOutbounds, s.defaultOutbounds...)
|
||||
@@ -322,22 +324,30 @@ func (s *SubJsonService) genVless(inbound *model.Inbound, streamSettings json_ut
|
||||
outbound.Mux = json_util.RawMessage(s.mux)
|
||||
}
|
||||
outbound.StreamSettings = streamSettings
|
||||
settings := make(map[string]any)
|
||||
settings["address"] = inbound.Listen
|
||||
settings["port"] = inbound.Port
|
||||
settings["id"] = client.ID
|
||||
if client.Flow != "" {
|
||||
settings["flow"] = client.Flow
|
||||
}
|
||||
|
||||
// Add encryption for VLESS outbound from inbound settings
|
||||
var inboundSettings map[string]any
|
||||
json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
|
||||
if encryption, ok := inboundSettings["encryption"].(string); ok {
|
||||
settings["encryption"] = encryption
|
||||
encryption, _ := inboundSettings["encryption"].(string)
|
||||
|
||||
user := map[string]any{
|
||||
"id": client.ID,
|
||||
"level": 8,
|
||||
"encryption": encryption,
|
||||
}
|
||||
if client.Flow != "" {
|
||||
user["flow"] = client.Flow
|
||||
}
|
||||
|
||||
outbound.Settings = settings
|
||||
vnext := map[string]any{
|
||||
"address": inbound.Listen,
|
||||
"port": inbound.Port,
|
||||
"users": []any{user},
|
||||
}
|
||||
|
||||
outbound.Settings = map[string]any{
|
||||
"vnext": []any{vnext},
|
||||
}
|
||||
result, _ := json.MarshalIndent(outbound, "", " ")
|
||||
return result
|
||||
}
|
||||
@@ -381,6 +391,49 @@ func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_u
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any, client model.Client) json_util.RawMessage {
|
||||
outbound := Outbound{}
|
||||
|
||||
outbound.Protocol = string(inbound.Protocol)
|
||||
outbound.Tag = "proxy"
|
||||
|
||||
if s.mux != "" {
|
||||
outbound.Mux = json_util.RawMessage(s.mux)
|
||||
}
|
||||
|
||||
var settings, stream map[string]any
|
||||
json.Unmarshal([]byte(inbound.Settings), &settings)
|
||||
version, _ := settings["version"].(float64)
|
||||
outbound.Settings = map[string]any{
|
||||
"version": int(version),
|
||||
"address": inbound.Listen,
|
||||
"port": inbound.Port,
|
||||
}
|
||||
|
||||
json.Unmarshal([]byte(inbound.StreamSettings), &stream)
|
||||
hyStream := stream["hysteriaSettings"].(map[string]any)
|
||||
outHyStream := map[string]any{
|
||||
"version": int(version),
|
||||
"auth": client.Auth,
|
||||
}
|
||||
if udpIdleTimeout, ok := hyStream["udpIdleTimeout"].(float64); ok {
|
||||
outHyStream["udpIdleTimeout"] = int(udpIdleTimeout)
|
||||
}
|
||||
newStream["hysteriaSettings"] = outHyStream
|
||||
|
||||
if finalmask, ok := hyStream["finalmask"].(map[string]any); ok {
|
||||
newStream["finalmask"] = finalmask
|
||||
}
|
||||
|
||||
newStream["network"] = "hysteria"
|
||||
newStream["security"] = "tls"
|
||||
|
||||
outbound.StreamSettings, _ = json.MarshalIndent(newStream, "", " ")
|
||||
|
||||
result, _ := json.MarshalIndent(outbound, "", " ")
|
||||
return result
|
||||
}
|
||||
|
||||
type Outbound struct {
|
||||
Protocol string `json:"protocol"`
|
||||
Tag string `json:"tag"`
|
||||
|
||||
+80
-16
@@ -120,7 +120,7 @@ func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error)
|
||||
FROM inbounds,
|
||||
JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client
|
||||
WHERE
|
||||
protocol in ('vmess','vless','trojan','shadowsocks')
|
||||
protocol in ('vmess','vless','trojan','shadowsocks','hysteria')
|
||||
AND JSON_EXTRACT(client.value, '$.subId') = ? AND enable = ?
|
||||
)`, subId, true).Find(&inbounds).Error
|
||||
if err != nil {
|
||||
@@ -171,6 +171,8 @@ func (s *SubService) getLink(inbound *model.Inbound, email string) string {
|
||||
return s.genTrojanLink(inbound, email)
|
||||
case "shadowsocks":
|
||||
return s.genShadowsocksLink(inbound, email)
|
||||
case "hysteria":
|
||||
return s.genHysteriaLink(inbound, email)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -247,7 +249,7 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
|
||||
headers, _ := xhttp["headers"].(map[string]any)
|
||||
obj["host"] = searchHost(headers)
|
||||
}
|
||||
obj["mode"] = xhttp["mode"].(string)
|
||||
obj["mode"], _ = xhttp["mode"].(string)
|
||||
}
|
||||
security, _ := stream["security"].(string)
|
||||
obj["tls"] = security
|
||||
@@ -405,7 +407,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
|
||||
headers, _ := xhttp["headers"].(map[string]any)
|
||||
params["host"] = searchHost(headers)
|
||||
}
|
||||
params["mode"] = xhttp["mode"].(string)
|
||||
params["mode"], _ = xhttp["mode"].(string)
|
||||
}
|
||||
security, _ := stream["security"].(string)
|
||||
if security == "tls" {
|
||||
@@ -601,7 +603,7 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
|
||||
headers, _ := xhttp["headers"].(map[string]any)
|
||||
params["host"] = searchHost(headers)
|
||||
}
|
||||
params["mode"] = xhttp["mode"].(string)
|
||||
params["mode"], _ = xhttp["mode"].(string)
|
||||
}
|
||||
security, _ := stream["security"].(string)
|
||||
if security == "tls" {
|
||||
@@ -800,7 +802,7 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
|
||||
headers, _ := xhttp["headers"].(map[string]any)
|
||||
params["host"] = searchHost(headers)
|
||||
}
|
||||
params["mode"] = xhttp["mode"].(string)
|
||||
params["mode"], _ = xhttp["mode"].(string)
|
||||
}
|
||||
|
||||
security, _ := stream["security"].(string)
|
||||
@@ -885,6 +887,70 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
|
||||
return url.String()
|
||||
}
|
||||
|
||||
func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) string {
|
||||
address := s.address
|
||||
if inbound.Protocol != model.Hysteria {
|
||||
return ""
|
||||
}
|
||||
var stream map[string]interface{}
|
||||
json.Unmarshal([]byte(inbound.StreamSettings), &stream)
|
||||
clients, _ := s.inboundService.GetClients(inbound)
|
||||
clientIndex := -1
|
||||
for i, client := range clients {
|
||||
if client.Email == email {
|
||||
clientIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
auth := clients[clientIndex].Auth
|
||||
port := inbound.Port
|
||||
params := make(map[string]string)
|
||||
|
||||
params["security"] = "tls"
|
||||
tlsSetting, _ := stream["tlsSettings"].(map[string]interface{})
|
||||
alpns, _ := tlsSetting["alpn"].([]interface{})
|
||||
var alpn []string
|
||||
for _, a := range alpns {
|
||||
alpn = append(alpn, a.(string))
|
||||
}
|
||||
if len(alpn) > 0 {
|
||||
params["alpn"] = strings.Join(alpn, ",")
|
||||
}
|
||||
if sniValue, ok := searchKey(tlsSetting, "serverName"); ok {
|
||||
params["sni"], _ = sniValue.(string)
|
||||
}
|
||||
|
||||
tlsSettings, _ := searchKey(tlsSetting, "settings")
|
||||
if tlsSetting != nil {
|
||||
if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok {
|
||||
params["fp"], _ = fpValue.(string)
|
||||
}
|
||||
if insecure, ok := searchKey(tlsSettings, "allowInsecure"); ok {
|
||||
if insecure.(bool) {
|
||||
params["insecure"] = "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var settings map[string]interface{}
|
||||
json.Unmarshal([]byte(inbound.Settings), &settings)
|
||||
version, _ := settings["version"].(float64)
|
||||
protocol := "hysteria2"
|
||||
if int(version) == 1 {
|
||||
protocol = "hysteria"
|
||||
}
|
||||
|
||||
link := fmt.Sprintf("%s://%s@%s:%d", protocol, auth, address, port)
|
||||
url, _ := url.Parse(link)
|
||||
q := url.Query()
|
||||
for k, v := range params {
|
||||
q.Add(k, v)
|
||||
}
|
||||
url.RawQuery = q.Encode()
|
||||
url.Fragment = s.genRemark(inbound, email, "")
|
||||
return url.String()
|
||||
}
|
||||
|
||||
func (s *SubService) genRemark(inbound *model.Inbound, email string, extra string) string {
|
||||
separationChar := string(s.remarkModel[0])
|
||||
orderChars := s.remarkModel[1:]
|
||||
@@ -1031,6 +1097,7 @@ type PageData struct {
|
||||
TotalByte int64
|
||||
SubUrl string
|
||||
SubJsonUrl string
|
||||
SubClashUrl string
|
||||
Result []string
|
||||
}
|
||||
|
||||
@@ -1080,29 +1147,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.
|
||||
// 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) {
|
||||
// Input validation
|
||||
func (s *SubService) BuildURLs(scheme, hostWithPort, subPath, subJsonPath, subClashPath, subId string) (subURL, subJsonURL, subClashURL string) {
|
||||
if subId == "" {
|
||||
return "", ""
|
||||
return "", "", ""
|
||||
}
|
||||
|
||||
// Get configured URIs first (highest priority)
|
||||
configuredSubURI, _ := s.settingService.GetSubURI()
|
||||
configuredSubJsonURI, _ := s.settingService.GetSubJsonURI()
|
||||
configuredSubClashURI, _ := s.settingService.GetSubClashURI()
|
||||
|
||||
// Determine base scheme and host (cached to avoid duplicate calls)
|
||||
var baseScheme, baseHostWithPort string
|
||||
if configuredSubURI == "" || configuredSubJsonURI == "" {
|
||||
if configuredSubURI == "" || configuredSubJsonURI == "" || configuredSubClashURI == "" {
|
||||
baseScheme, baseHostWithPort = s.getBaseSchemeAndHost(scheme, hostWithPort)
|
||||
}
|
||||
|
||||
// Build subscription URL
|
||||
subURL = s.buildSingleURL(configuredSubURI, baseScheme, baseHostWithPort, subPath, subId)
|
||||
|
||||
// Build JSON subscription URL
|
||||
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
|
||||
@@ -1149,7 +1212,7 @@ func (s *SubService) joinPathWithID(basePath, subId string) string {
|
||||
|
||||
// BuildPageData parses header and prepares the template view model.
|
||||
// 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)
|
||||
upload := common.FormatTraffic(traffic.Up)
|
||||
total := "∞"
|
||||
@@ -1183,6 +1246,7 @@ func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray
|
||||
TotalByte: traffic.Total,
|
||||
SubUrl: subURL,
|
||||
SubJsonUrl: subJsonURL,
|
||||
SubClashUrl: subClashURL,
|
||||
Result: subs,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,37 +100,38 @@ is_port_in_use() {
|
||||
|
||||
gen_random_string() {
|
||||
local length="$1"
|
||||
local random_string=$(LC_ALL=C tr -dc 'a-zA-Z0-9' </dev/urandom | fold -w "$length" | head -n 1)
|
||||
echo "$random_string"
|
||||
openssl rand -base64 $(( length * 2 )) \
|
||||
| tr -dc 'a-zA-Z0-9' \
|
||||
| head -c "$length"
|
||||
}
|
||||
|
||||
install_base() {
|
||||
echo -e "${green}Updating and install dependency packages...${plain}"
|
||||
case "${release}" in
|
||||
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)
|
||||
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)
|
||||
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
|
||||
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
|
||||
;;
|
||||
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)
|
||||
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)
|
||||
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
|
||||
}
|
||||
@@ -401,15 +402,15 @@ ssl_cert_issue() {
|
||||
break
|
||||
done
|
||||
echo -e "${green}Your domain is: ${domain}, checking it...${plain}"
|
||||
SSL_ISSUED_DOMAIN="${domain}"
|
||||
|
||||
# check if there already exists a certificate
|
||||
local currentCert=$(~/.acme.sh/acme.sh --list | tail -1 | awk '{print $1}')
|
||||
if [ "${currentCert}" == "${domain}" ]; then
|
||||
local certInfo=$(~/.acme.sh/acme.sh --list)
|
||||
echo -e "${red}System already has certificates for this domain. Cannot issue again.${plain}"
|
||||
echo -e "${yellow}Current certificate details:${plain}"
|
||||
echo "$certInfo"
|
||||
return 1
|
||||
# detect existing certificate and reuse it if present
|
||||
local cert_exists=0
|
||||
if ~/.acme.sh/acme.sh --list 2>/dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
|
||||
cert_exists=1
|
||||
local certInfo=$(~/.acme.sh/acme.sh --list 2>/dev/null | grep -F "${domain}")
|
||||
echo -e "${yellow}Existing certificate found for ${domain}, will reuse it.${plain}"
|
||||
[[ -n "${certInfo}" ]] && echo "$certInfo"
|
||||
else
|
||||
echo -e "${green}Your domain is ready for issuing certificates now...${plain}"
|
||||
fi
|
||||
@@ -436,16 +437,20 @@ ssl_cert_issue() {
|
||||
echo -e "${yellow}Stopping panel temporarily...${plain}"
|
||||
systemctl stop x-ui 2>/dev/null || rc-service x-ui stop 2>/dev/null
|
||||
|
||||
# issue the certificate
|
||||
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force
|
||||
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${red}Issuing certificate failed, please check logs.${plain}"
|
||||
rm -rf ~/.acme.sh/${domain}
|
||||
systemctl start x-ui 2>/dev/null || rc-service x-ui start 2>/dev/null
|
||||
return 1
|
||||
if [[ ${cert_exists} -eq 0 ]]; then
|
||||
# issue the certificate
|
||||
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force
|
||||
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${red}Issuing certificate failed, please check logs.${plain}"
|
||||
rm -rf ~/.acme.sh/${domain}
|
||||
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
|
||||
echo -e "${green}Issuing certificate succeeded, installing certificates...${plain}"
|
||||
echo -e "${green}Using existing certificate, installing certificates...${plain}"
|
||||
fi
|
||||
|
||||
# Setup reload command
|
||||
@@ -475,17 +480,27 @@ ssl_cert_issue() {
|
||||
fi
|
||||
|
||||
# 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 \
|
||||
--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}"
|
||||
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
|
||||
return 1
|
||||
else
|
||||
echo -e "${green}Installing certificate succeeded, enabling auto renew...${plain}"
|
||||
fi
|
||||
|
||||
# enable auto-renew
|
||||
@@ -555,14 +570,21 @@ prompt_and_setup_ssl() {
|
||||
1)
|
||||
# User chose Let's Encrypt domain option
|
||||
echo -e "${green}Using Let's Encrypt for domain certificate...${plain}"
|
||||
ssl_cert_issue
|
||||
# Extract the domain that was used from the certificate
|
||||
local cert_domain=$(~/.acme.sh/acme.sh --list 2>/dev/null | tail -1 | awk '{print $1}')
|
||||
if [[ -n "${cert_domain}" ]]; then
|
||||
SSL_HOST="${cert_domain}"
|
||||
echo -e "${green}✓ SSL certificate configured successfully with domain: ${cert_domain}${plain}"
|
||||
if ssl_cert_issue; then
|
||||
local cert_domain="${SSL_ISSUED_DOMAIN}"
|
||||
if [[ -z "${cert_domain}" ]]; then
|
||||
cert_domain=$(~/.acme.sh/acme.sh --list 2>/dev/null | tail -1 | awk '{print $1}')
|
||||
fi
|
||||
|
||||
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
|
||||
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}"
|
||||
fi
|
||||
;;
|
||||
@@ -608,7 +630,7 @@ prompt_and_setup_ssl() {
|
||||
|
||||
# 3.1 Request Domain to compose Panel URL later
|
||||
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
|
||||
while true; do
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -90,7 +90,16 @@ class DBInbound {
|
||||
return this.expiryTime < new Date().getTime();
|
||||
}
|
||||
|
||||
invalidateCache() {
|
||||
this._cachedInbound = null;
|
||||
this._clientStatsMap = null;
|
||||
}
|
||||
|
||||
toInbound() {
|
||||
if (this._cachedInbound) {
|
||||
return this._cachedInbound;
|
||||
}
|
||||
|
||||
let settings = {};
|
||||
if (!ObjectUtil.isEmpty(this.settings)) {
|
||||
settings = JSON.parse(this.settings);
|
||||
@@ -116,7 +125,21 @@ class DBInbound {
|
||||
sniffing: sniffing,
|
||||
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() {
|
||||
@@ -124,6 +147,7 @@ class DBInbound {
|
||||
case Protocols.VMESS:
|
||||
case Protocols.VLESS:
|
||||
case Protocols.TROJAN:
|
||||
case Protocols.HYSTERIA:
|
||||
return true;
|
||||
case Protocols.SHADOWSOCKS:
|
||||
return this.toInbound().isSSMultiUser;
|
||||
@@ -138,6 +162,7 @@ class DBInbound {
|
||||
case Protocols.VLESS:
|
||||
case Protocols.TROJAN:
|
||||
case Protocols.SHADOWSOCKS:
|
||||
case Protocols.HYSTERIA:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
|
||||
+375
-287
@@ -8,6 +8,7 @@ const Protocols = {
|
||||
HTTP: 'http',
|
||||
WIREGUARD: 'wireguard',
|
||||
TUN: 'tun',
|
||||
HYSTERIA: 'hysteria',
|
||||
};
|
||||
|
||||
const SSMethods = {
|
||||
@@ -322,18 +323,16 @@ class KcpStreamSettings extends XrayCommonClass {
|
||||
tti = 20,
|
||||
uplinkCapacity = 5,
|
||||
downlinkCapacity = 20,
|
||||
congestion = false,
|
||||
readBufferSize = 1,
|
||||
writeBufferSize = 1,
|
||||
cwndMultiplier = 0,
|
||||
maxSendingWindow = 0,
|
||||
) {
|
||||
super();
|
||||
this.mtu = mtu;
|
||||
this.tti = tti;
|
||||
this.upCap = uplinkCapacity;
|
||||
this.downCap = downlinkCapacity;
|
||||
this.congestion = congestion;
|
||||
this.readBuffer = readBufferSize;
|
||||
this.writeBuffer = writeBufferSize;
|
||||
this.cwndMultiplier = cwndMultiplier;
|
||||
this.maxSendingWindow = maxSendingWindow;
|
||||
}
|
||||
|
||||
static fromJson(json = {}) {
|
||||
@@ -342,9 +341,8 @@ class KcpStreamSettings extends XrayCommonClass {
|
||||
json.tti,
|
||||
json.uplinkCapacity,
|
||||
json.downlinkCapacity,
|
||||
json.congestion,
|
||||
json.readBufferSize,
|
||||
json.writeBufferSize,
|
||||
json.cwndMultiplier,
|
||||
json.maxSendingWindow,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -354,9 +352,8 @@ class KcpStreamSettings extends XrayCommonClass {
|
||||
tti: this.tti,
|
||||
uplinkCapacity: this.upCap,
|
||||
downlinkCapacity: this.downCap,
|
||||
congestion: this.congestion,
|
||||
readBufferSize: this.readBuffer,
|
||||
writeBufferSize: this.writeBuffer,
|
||||
cwndMultiplier: this.cwndMultiplier,
|
||||
maxSendingWindow: this.maxSendingWindow,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -589,6 +586,106 @@ class xHTTPStreamSettings extends XrayCommonClass {
|
||||
}
|
||||
}
|
||||
|
||||
class HysteriaStreamSettings extends XrayCommonClass {
|
||||
constructor(
|
||||
protocol,
|
||||
version = 2,
|
||||
auth = '',
|
||||
udpIdleTimeout = 60,
|
||||
masquerade,
|
||||
) {
|
||||
super(protocol);
|
||||
this.version = version;
|
||||
this.auth = auth;
|
||||
this.udpIdleTimeout = udpIdleTimeout;
|
||||
this.masquerade = masquerade;
|
||||
}
|
||||
|
||||
static fromJson(json = {}) {
|
||||
return new HysteriaStreamSettings(
|
||||
json.protocol,
|
||||
json.version ?? 2,
|
||||
json.auth ?? '',
|
||||
json.udpIdleTimeout ?? 60,
|
||||
json.masquerade ? HysteriaMasquerade.fromJson(json.masquerade) : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
toJson() {
|
||||
return {
|
||||
protocol: this.protocol,
|
||||
version: this.version,
|
||||
auth: this.auth,
|
||||
udpIdleTimeout: this.udpIdleTimeout,
|
||||
masquerade: this.masqueradeSwitch ? this.masquerade.toJson() : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
get masqueradeSwitch() {
|
||||
return this.masquerade != undefined;
|
||||
}
|
||||
|
||||
set masqueradeSwitch(value) {
|
||||
this.masquerade = value ? new HysteriaMasquerade() : undefined;
|
||||
}
|
||||
};
|
||||
|
||||
class HysteriaMasquerade extends XrayCommonClass {
|
||||
constructor(
|
||||
type = 'proxy',
|
||||
dir = '',
|
||||
url = '',
|
||||
rewriteHost = false,
|
||||
insecure = false,
|
||||
content = '',
|
||||
headers = [],
|
||||
statusCode = 0,
|
||||
) {
|
||||
super();
|
||||
this.type = type;
|
||||
this.dir = dir;
|
||||
this.url = url;
|
||||
this.rewriteHost = rewriteHost;
|
||||
this.insecure = insecure;
|
||||
this.content = content;
|
||||
this.headers = headers;
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
|
||||
addHeader(name, value) {
|
||||
this.headers.push({ name: name, value: value });
|
||||
}
|
||||
|
||||
removeHeader(index) {
|
||||
this.headers.splice(index, 1);
|
||||
}
|
||||
|
||||
static fromJson(json = {}) {
|
||||
return new HysteriaMasquerade(
|
||||
json.type,
|
||||
json.dir,
|
||||
json.url,
|
||||
json.rewriteHost,
|
||||
json.insecure,
|
||||
json.content,
|
||||
XrayCommonClass.toHeaders(json.headers),
|
||||
json.statusCode,
|
||||
);
|
||||
}
|
||||
|
||||
toJson() {
|
||||
return {
|
||||
type: this.type,
|
||||
dir: this.dir,
|
||||
url: this.url,
|
||||
rewriteHost: this.rewriteHost,
|
||||
insecure: this.insecure,
|
||||
content: this.content,
|
||||
headers: XrayCommonClass.toV2Headers(this.headers, false),
|
||||
statusCode: this.statusCode,
|
||||
};
|
||||
}
|
||||
};
|
||||
class TlsStreamSettings extends XrayCommonClass {
|
||||
constructor(
|
||||
serverName = '',
|
||||
@@ -976,8 +1073,9 @@ class UdpMask extends XrayCommonClass {
|
||||
case 'mkcp-aes128gcm':
|
||||
return { password: settings.password || '' };
|
||||
case 'header-dns':
|
||||
case 'xdns':
|
||||
return { domain: settings.domain || '' };
|
||||
case 'xdns':
|
||||
return { domains: Array.isArray(settings.domains) ? settings.domains : [] };
|
||||
case 'xicmp':
|
||||
return { ip: settings.ip || '', id: settings.id ?? 0 };
|
||||
case 'mkcp-original':
|
||||
@@ -987,6 +1085,12 @@ class UdpMask extends XrayCommonClass {
|
||||
case 'header-wechat':
|
||||
case 'header-wireguard':
|
||||
return {};
|
||||
case 'header-custom':
|
||||
return { client: [], server: [] };
|
||||
case 'noise':
|
||||
return { reset: 0, noise: [] };
|
||||
case 'sudoku':
|
||||
return { ascii: '', customTable: '', customTables: [], paddingMin: 0, paddingMax: 0 };
|
||||
default:
|
||||
return settings;
|
||||
}
|
||||
@@ -1021,7 +1125,6 @@ class FinalMaskStreamSettings extends XrayCommonClass {
|
||||
return {
|
||||
udp: this.udp.map(udp => udp.toJson())
|
||||
};
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1037,6 +1140,7 @@ class StreamSettings extends XrayCommonClass {
|
||||
grpcSettings = new GrpcStreamSettings(),
|
||||
httpupgradeSettings = new HTTPUpgradeStreamSettings(),
|
||||
xhttpSettings = new xHTTPStreamSettings(),
|
||||
hysteriaSettings = new HysteriaStreamSettings(),
|
||||
finalmask = new FinalMaskStreamSettings(),
|
||||
sockopt = undefined,
|
||||
) {
|
||||
@@ -1052,6 +1156,7 @@ class StreamSettings extends XrayCommonClass {
|
||||
this.grpc = grpcSettings;
|
||||
this.httpupgrade = httpupgradeSettings;
|
||||
this.xhttp = xhttpSettings;
|
||||
this.hysteria = hysteriaSettings;
|
||||
this.finalmask = finalmask;
|
||||
this.sockopt = sockopt;
|
||||
}
|
||||
@@ -1116,6 +1221,7 @@ class StreamSettings extends XrayCommonClass {
|
||||
GrpcStreamSettings.fromJson(json.grpcSettings),
|
||||
HTTPUpgradeStreamSettings.fromJson(json.httpupgradeSettings),
|
||||
xHTTPStreamSettings.fromJson(json.xhttpSettings),
|
||||
HysteriaStreamSettings.fromJson(json.hysteriaSettings),
|
||||
FinalMaskStreamSettings.fromJson(json.finalmask),
|
||||
SockoptStreamSettings.fromJson(json.sockopt),
|
||||
);
|
||||
@@ -1135,6 +1241,7 @@ class StreamSettings extends XrayCommonClass {
|
||||
grpcSettings: network === 'grpc' ? this.grpc.toJson() : undefined,
|
||||
httpupgradeSettings: network === 'httpupgrade' ? this.httpupgrade.toJson() : undefined,
|
||||
xhttpSettings: network === 'xhttp' ? this.xhttp.toJson() : undefined,
|
||||
hysteriaSettings: network === 'hysteria' ? this.hysteria.toJson() : undefined,
|
||||
finalmask: this.hasFinalMask ? this.finalmask.toJson() : undefined,
|
||||
sockopt: this.sockopt != undefined ? this.sockopt.toJson() : undefined,
|
||||
};
|
||||
@@ -1146,28 +1253,43 @@ class Sniffing extends XrayCommonClass {
|
||||
enabled = false,
|
||||
destOverride = ['http', 'tls', 'quic', 'fakedns'],
|
||||
metadataOnly = false,
|
||||
routeOnly = false) {
|
||||
routeOnly = false,
|
||||
ipsExcluded = [],
|
||||
domainsExcluded = []) {
|
||||
super();
|
||||
this.enabled = enabled;
|
||||
this.destOverride = destOverride;
|
||||
this.destOverride = Array.isArray(destOverride) && destOverride.length > 0 ? destOverride : ['http', 'tls', 'quic', 'fakedns'];
|
||||
this.metadataOnly = metadataOnly;
|
||||
this.routeOnly = routeOnly;
|
||||
this.ipsExcluded = Array.isArray(ipsExcluded) ? ipsExcluded : [];
|
||||
this.domainsExcluded = Array.isArray(domainsExcluded) ? domainsExcluded : [];
|
||||
}
|
||||
|
||||
static fromJson(json = {}) {
|
||||
let destOverride = ObjectUtil.clone(json.destOverride);
|
||||
if (!ObjectUtil.isEmpty(destOverride) && !ObjectUtil.isArrEmpty(destOverride)) {
|
||||
if (ObjectUtil.isEmpty(destOverride[0])) {
|
||||
destOverride = ['http', 'tls', 'quic', 'fakedns'];
|
||||
}
|
||||
if (ObjectUtil.isEmpty(destOverride) || ObjectUtil.isArrEmpty(destOverride) || ObjectUtil.isEmpty(destOverride[0])) {
|
||||
destOverride = ['http', 'tls', 'quic', 'fakedns'];
|
||||
}
|
||||
return new Sniffing(
|
||||
!!json.enabled,
|
||||
destOverride,
|
||||
json.metadataOnly,
|
||||
json.routeOnly,
|
||||
json.ipsExcluded || [],
|
||||
json.domainsExcluded || [],
|
||||
);
|
||||
}
|
||||
|
||||
toJson() {
|
||||
return {
|
||||
enabled: this.enabled,
|
||||
destOverride: this.destOverride,
|
||||
metadataOnly: this.metadataOnly,
|
||||
routeOnly: this.routeOnly,
|
||||
ipsExcluded: this.ipsExcluded.length > 0 ? this.ipsExcluded : undefined,
|
||||
domainsExcluded: this.domainsExcluded.length > 0 ? this.domainsExcluded : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class Inbound extends XrayCommonClass {
|
||||
@@ -1201,6 +1323,7 @@ class Inbound extends XrayCommonClass {
|
||||
case Protocols.VLESS: return this.settings.vlesses;
|
||||
case Protocols.TROJAN: return this.settings.trojans;
|
||||
case Protocols.SHADOWSOCKS: return this.isSSMultiUser ? this.settings.shadowsockses : null;
|
||||
case Protocols.HYSTERIA: return this.settings.hysterias;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
@@ -1212,9 +1335,14 @@ class Inbound extends XrayCommonClass {
|
||||
set protocol(protocol) {
|
||||
this._protocol = protocol;
|
||||
this.settings = Inbound.Settings.getSettings(protocol);
|
||||
this.stream = new StreamSettings();
|
||||
if (protocol === Protocols.TROJAN) {
|
||||
this.tls = false;
|
||||
}
|
||||
if (protocol === Protocols.HYSTERIA) {
|
||||
this.stream.network = 'hysteria';
|
||||
this.stream.security = 'tls';
|
||||
}
|
||||
}
|
||||
|
||||
get network() {
|
||||
@@ -1316,6 +1444,7 @@ class Inbound extends XrayCommonClass {
|
||||
}
|
||||
|
||||
canEnableTls() {
|
||||
if (this.protocol === Protocols.HYSTERIA) return true;
|
||||
if (![Protocols.VMESS, Protocols.VLESS, Protocols.TROJAN, Protocols.SHADOWSOCKS].includes(this.protocol)) return false;
|
||||
return ["tcp", "ws", "http", "grpc", "httpupgrade", "xhttp"].includes(this.network);
|
||||
}
|
||||
@@ -1342,7 +1471,7 @@ class Inbound extends XrayCommonClass {
|
||||
}
|
||||
|
||||
canEnableStream() {
|
||||
return [Protocols.VMESS, Protocols.VLESS, Protocols.TROJAN, Protocols.SHADOWSOCKS].includes(this.protocol);
|
||||
return [Protocols.VMESS, Protocols.VLESS, Protocols.TROJAN, Protocols.SHADOWSOCKS, Protocols.HYSTERIA].includes(this.protocol);
|
||||
}
|
||||
|
||||
reset() {
|
||||
@@ -1689,6 +1818,26 @@ class Inbound extends XrayCommonClass {
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
genHysteriaLink(address = '', port = this.port, remark = '', clientAuth) {
|
||||
const protocol = this.settings.version == 2 ? "hysteria2" : "hysteria";
|
||||
const link = `${protocol}://${clientAuth}@${address}:${port}`;
|
||||
|
||||
const params = new Map();
|
||||
params.set("security", "tls");
|
||||
if (this.stream.tls.settings.fingerprint?.length > 0) params.set("fp", this.stream.tls.settings.fingerprint);
|
||||
if (this.stream.tls.alpn?.length > 0) params.set("alpn", this.stream.tls.alpn);
|
||||
if (this.stream.tls.settings.allowInsecure) params.set("insecure", "1");
|
||||
if (this.stream.tls.settings.echConfigList?.length > 0) params.set("ech", this.stream.tls.settings.echConfigList.join(','));
|
||||
if (this.stream.tls.sni?.length > 0) params.set("sni", this.stream.tls.sni);
|
||||
|
||||
const url = new URL(link);
|
||||
for (const [key, value] of params) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
url.hash = encodeURIComponent(remark);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
getWireguardLink(address, port, remark, peerId) {
|
||||
let txt = `[Interface]\n`
|
||||
txt += `PrivateKey = ${this.settings.peers[peerId].privateKey}\n`
|
||||
@@ -1721,6 +1870,8 @@ class Inbound extends XrayCommonClass {
|
||||
return this.genSSLink(address, port, forceTls, remark, this.isSSMultiUser ? client.password : '');
|
||||
case Protocols.TROJAN:
|
||||
return this.genTrojanLink(address, port, forceTls, remark, client.password);
|
||||
case Protocols.HYSTERIA:
|
||||
return this.genHysteriaLink(address, port, remark, client.auth.length > 0 ? client.auth : this.stream.hysteria.auth);
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
@@ -1827,6 +1978,7 @@ Inbound.Settings = class extends XrayCommonClass {
|
||||
case Protocols.HTTP: return new Inbound.HttpSettings(protocol);
|
||||
case Protocols.WIREGUARD: return new Inbound.WireguardSettings(protocol);
|
||||
case Protocols.TUN: return new Inbound.TunSettings(protocol);
|
||||
case Protocols.HYSTERIA: return new Inbound.HysteriaSettings(protocol);
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
@@ -1842,6 +1994,7 @@ Inbound.Settings = class extends XrayCommonClass {
|
||||
case Protocols.HTTP: return Inbound.HttpSettings.fromJson(json);
|
||||
case Protocols.WIREGUARD: return Inbound.WireguardSettings.fromJson(json);
|
||||
case Protocols.TUN: return Inbound.TunSettings.fromJson(json);
|
||||
case Protocols.HYSTERIA: return Inbound.HysteriaSettings.fromJson(json);
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
@@ -1851,6 +2004,94 @@ Inbound.Settings = class extends XrayCommonClass {
|
||||
}
|
||||
};
|
||||
|
||||
/** Shared user-quota fields and UI helpers for multi-user protocol clients. */
|
||||
Inbound.ClientBase = class extends XrayCommonClass {
|
||||
constructor(
|
||||
email = RandomUtil.randomLowerAndNum(8),
|
||||
limitIp = 0,
|
||||
totalGB = 0,
|
||||
expiryTime = 0,
|
||||
enable = true,
|
||||
tgId = '',
|
||||
subId = RandomUtil.randomLowerAndNum(16),
|
||||
comment = '',
|
||||
reset = 0,
|
||||
created_at = undefined,
|
||||
updated_at = undefined,
|
||||
) {
|
||||
super();
|
||||
this.email = email;
|
||||
this.limitIp = limitIp;
|
||||
this.totalGB = totalGB;
|
||||
this.expiryTime = expiryTime;
|
||||
this.enable = enable;
|
||||
this.tgId = tgId;
|
||||
this.subId = subId;
|
||||
this.comment = comment;
|
||||
this.reset = reset;
|
||||
this.created_at = created_at;
|
||||
this.updated_at = updated_at;
|
||||
}
|
||||
|
||||
static commonArgsFromJson(json = {}) {
|
||||
return [
|
||||
json.email,
|
||||
json.limitIp,
|
||||
json.totalGB,
|
||||
json.expiryTime,
|
||||
json.enable,
|
||||
json.tgId,
|
||||
json.subId,
|
||||
json.comment,
|
||||
json.reset,
|
||||
json.created_at,
|
||||
json.updated_at,
|
||||
];
|
||||
}
|
||||
|
||||
_clientBaseToJson() {
|
||||
return {
|
||||
email: this.email,
|
||||
limitIp: this.limitIp,
|
||||
totalGB: this.totalGB,
|
||||
expiryTime: this.expiryTime,
|
||||
enable: this.enable,
|
||||
tgId: this.tgId,
|
||||
subId: this.subId,
|
||||
comment: this.comment,
|
||||
reset: this.reset,
|
||||
created_at: this.created_at,
|
||||
updated_at: this.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
get _expiryTime() {
|
||||
if (this.expiryTime === 0 || this.expiryTime === '') {
|
||||
return null;
|
||||
}
|
||||
if (this.expiryTime < 0) {
|
||||
return this.expiryTime / -86400000;
|
||||
}
|
||||
return moment(this.expiryTime);
|
||||
}
|
||||
|
||||
set _expiryTime(t) {
|
||||
if (t == null || t === '') {
|
||||
this.expiryTime = 0;
|
||||
} else {
|
||||
this.expiryTime = t.valueOf();
|
||||
}
|
||||
}
|
||||
|
||||
get _totalGB() {
|
||||
return NumberFormatter.toFixed(this.totalGB / SizeFormatter.ONE_GB, 2);
|
||||
}
|
||||
|
||||
set _totalGB(gb) {
|
||||
this.totalGB = NumberFormatter.toFixed(gb * SizeFormatter.ONE_GB, 0);
|
||||
}
|
||||
};
|
||||
|
||||
Inbound.VmessSettings = class extends Inbound.Settings {
|
||||
constructor(protocol,
|
||||
vmesses = [new Inbound.VmessSettings.VMESS()]) {
|
||||
@@ -1879,7 +2120,7 @@ Inbound.VmessSettings = class extends Inbound.Settings {
|
||||
static fromJson(json = {}) {
|
||||
return new Inbound.VmessSettings(
|
||||
Protocols.VMESS,
|
||||
json.clients.map(client => Inbound.VmessSettings.VMESS.fromJson(client)),
|
||||
(json.clients || []).map(client => Inbound.VmessSettings.VMESS.fromJson(client)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1890,80 +2131,32 @@ Inbound.VmessSettings = class extends Inbound.Settings {
|
||||
}
|
||||
};
|
||||
|
||||
Inbound.VmessSettings.VMESS = class extends XrayCommonClass {
|
||||
Inbound.VmessSettings.VMESS = class extends Inbound.ClientBase {
|
||||
constructor(
|
||||
id = RandomUtil.randomUUID(),
|
||||
security = USERS_SECURITY.AUTO,
|
||||
email = RandomUtil.randomLowerAndNum(8),
|
||||
limitIp = 0,
|
||||
totalGB = 0,
|
||||
expiryTime = 0,
|
||||
enable = true,
|
||||
tgId = '',
|
||||
subId = RandomUtil.randomLowerAndNum(16),
|
||||
comment = '',
|
||||
reset = 0,
|
||||
created_at = undefined,
|
||||
updated_at = undefined
|
||||
email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at,
|
||||
) {
|
||||
super();
|
||||
super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at);
|
||||
this.id = id;
|
||||
this.security = security;
|
||||
this.email = email;
|
||||
this.limitIp = limitIp;
|
||||
this.totalGB = totalGB;
|
||||
this.expiryTime = expiryTime;
|
||||
this.enable = enable;
|
||||
this.tgId = tgId;
|
||||
this.subId = subId;
|
||||
this.comment = comment;
|
||||
this.reset = reset;
|
||||
this.created_at = created_at;
|
||||
this.updated_at = updated_at;
|
||||
}
|
||||
|
||||
static fromJson(json = {}) {
|
||||
return new Inbound.VmessSettings.VMESS(
|
||||
json.id,
|
||||
json.security,
|
||||
json.email,
|
||||
json.limitIp,
|
||||
json.totalGB,
|
||||
json.expiryTime,
|
||||
json.enable,
|
||||
json.tgId,
|
||||
json.subId,
|
||||
json.comment,
|
||||
json.reset,
|
||||
json.created_at,
|
||||
json.updated_at,
|
||||
...Inbound.ClientBase.commonArgsFromJson(json),
|
||||
);
|
||||
}
|
||||
get _expiryTime() {
|
||||
if (this.expiryTime === 0 || this.expiryTime === "") {
|
||||
return null;
|
||||
}
|
||||
if (this.expiryTime < 0) {
|
||||
return this.expiryTime / -86400000;
|
||||
}
|
||||
return moment(this.expiryTime);
|
||||
}
|
||||
|
||||
set _expiryTime(t) {
|
||||
if (t == null || t === "") {
|
||||
this.expiryTime = 0;
|
||||
} else {
|
||||
this.expiryTime = t.valueOf();
|
||||
}
|
||||
toJson() {
|
||||
return {
|
||||
id: this.id,
|
||||
security: this.security,
|
||||
...this._clientBaseToJson(),
|
||||
};
|
||||
}
|
||||
get _totalGB() {
|
||||
return NumberFormatter.toFixed(this.totalGB / SizeFormatter.ONE_GB, 2);
|
||||
}
|
||||
|
||||
set _totalGB(gb) {
|
||||
this.totalGB = NumberFormatter.toFixed(gb * SizeFormatter.ONE_GB, 0);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
Inbound.VLESSSettings = class extends Inbound.Settings {
|
||||
@@ -2041,85 +2234,36 @@ Inbound.VLESSSettings = class extends Inbound.Settings {
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
|
||||
Inbound.VLESSSettings.VLESS = class extends XrayCommonClass {
|
||||
Inbound.VLESSSettings.VLESS = class extends Inbound.ClientBase {
|
||||
constructor(
|
||||
id = RandomUtil.randomUUID(),
|
||||
flow = '',
|
||||
email = RandomUtil.randomLowerAndNum(8),
|
||||
limitIp = 0,
|
||||
totalGB = 0,
|
||||
expiryTime = 0,
|
||||
enable = true,
|
||||
tgId = '',
|
||||
subId = RandomUtil.randomLowerAndNum(16),
|
||||
comment = '',
|
||||
reset = 0,
|
||||
created_at = undefined,
|
||||
updated_at = undefined
|
||||
email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at,
|
||||
) {
|
||||
super();
|
||||
super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at);
|
||||
this.id = id;
|
||||
this.flow = flow;
|
||||
this.email = email;
|
||||
this.limitIp = limitIp;
|
||||
this.totalGB = totalGB;
|
||||
this.expiryTime = expiryTime;
|
||||
this.enable = enable;
|
||||
this.tgId = tgId;
|
||||
this.subId = subId;
|
||||
this.comment = comment;
|
||||
this.reset = reset;
|
||||
this.created_at = created_at;
|
||||
this.updated_at = updated_at;
|
||||
}
|
||||
|
||||
static fromJson(json = {}) {
|
||||
return new Inbound.VLESSSettings.VLESS(
|
||||
json.id,
|
||||
json.flow,
|
||||
json.email,
|
||||
json.limitIp,
|
||||
json.totalGB,
|
||||
json.expiryTime,
|
||||
json.enable,
|
||||
json.tgId,
|
||||
json.subId,
|
||||
json.comment,
|
||||
json.reset,
|
||||
json.created_at,
|
||||
json.updated_at,
|
||||
...Inbound.ClientBase.commonArgsFromJson(json),
|
||||
);
|
||||
}
|
||||
|
||||
get _expiryTime() {
|
||||
if (this.expiryTime === 0 || this.expiryTime === "") {
|
||||
return null;
|
||||
}
|
||||
if (this.expiryTime < 0) {
|
||||
return this.expiryTime / -86400000;
|
||||
}
|
||||
return moment(this.expiryTime);
|
||||
}
|
||||
|
||||
set _expiryTime(t) {
|
||||
if (t == null || t === "") {
|
||||
this.expiryTime = 0;
|
||||
} else {
|
||||
this.expiryTime = t.valueOf();
|
||||
}
|
||||
}
|
||||
get _totalGB() {
|
||||
return NumberFormatter.toFixed(this.totalGB / SizeFormatter.ONE_GB, 2);
|
||||
}
|
||||
|
||||
set _totalGB(gb) {
|
||||
this.totalGB = NumberFormatter.toFixed(gb * SizeFormatter.ONE_GB, 0);
|
||||
toJson() {
|
||||
return {
|
||||
id: this.id,
|
||||
flow: this.flow,
|
||||
...this._clientBaseToJson(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
Inbound.VLESSSettings.Fallback = class extends XrayCommonClass {
|
||||
constructor(name = "", alpn = '', path = '', dest = '', xver = 0) {
|
||||
super();
|
||||
@@ -2179,7 +2323,7 @@ Inbound.TrojanSettings = class extends Inbound.Settings {
|
||||
static fromJson(json = {}) {
|
||||
return new Inbound.TrojanSettings(
|
||||
Protocols.TROJAN,
|
||||
json.clients.map(client => Inbound.TrojanSettings.Trojan.fromJson(client)),
|
||||
(json.clients || []).map(client => Inbound.TrojanSettings.Trojan.fromJson(client)),
|
||||
Inbound.TrojanSettings.Fallback.fromJson(json.fallbacks),);
|
||||
}
|
||||
|
||||
@@ -2191,95 +2335,28 @@ Inbound.TrojanSettings = class extends Inbound.Settings {
|
||||
}
|
||||
};
|
||||
|
||||
Inbound.TrojanSettings.Trojan = class extends XrayCommonClass {
|
||||
Inbound.TrojanSettings.Trojan = class extends Inbound.ClientBase {
|
||||
constructor(
|
||||
password = RandomUtil.randomSeq(10),
|
||||
email = RandomUtil.randomLowerAndNum(8),
|
||||
limitIp = 0,
|
||||
totalGB = 0,
|
||||
expiryTime = 0,
|
||||
enable = true,
|
||||
tgId = '',
|
||||
subId = RandomUtil.randomLowerAndNum(16),
|
||||
comment = '',
|
||||
reset = 0,
|
||||
created_at = undefined,
|
||||
updated_at = undefined
|
||||
email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at,
|
||||
) {
|
||||
super();
|
||||
super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at);
|
||||
this.password = password;
|
||||
this.email = email;
|
||||
this.limitIp = limitIp;
|
||||
this.totalGB = totalGB;
|
||||
this.expiryTime = expiryTime;
|
||||
this.enable = enable;
|
||||
this.tgId = tgId;
|
||||
this.subId = subId;
|
||||
this.comment = comment;
|
||||
this.reset = reset;
|
||||
this.created_at = created_at;
|
||||
this.updated_at = updated_at;
|
||||
}
|
||||
|
||||
toJson() {
|
||||
return {
|
||||
password: this.password,
|
||||
email: this.email,
|
||||
limitIp: this.limitIp,
|
||||
totalGB: this.totalGB,
|
||||
expiryTime: this.expiryTime,
|
||||
enable: this.enable,
|
||||
tgId: this.tgId,
|
||||
subId: this.subId,
|
||||
comment: this.comment,
|
||||
reset: this.reset,
|
||||
created_at: this.created_at,
|
||||
updated_at: this.updated_at,
|
||||
...this._clientBaseToJson(),
|
||||
};
|
||||
}
|
||||
|
||||
static fromJson(json = {}) {
|
||||
return new Inbound.TrojanSettings.Trojan(
|
||||
json.password,
|
||||
json.email,
|
||||
json.limitIp,
|
||||
json.totalGB,
|
||||
json.expiryTime,
|
||||
json.enable,
|
||||
json.tgId,
|
||||
json.subId,
|
||||
json.comment,
|
||||
json.reset,
|
||||
json.created_at,
|
||||
json.updated_at,
|
||||
...Inbound.ClientBase.commonArgsFromJson(json),
|
||||
);
|
||||
}
|
||||
|
||||
get _expiryTime() {
|
||||
if (this.expiryTime === 0 || this.expiryTime === "") {
|
||||
return null;
|
||||
}
|
||||
if (this.expiryTime < 0) {
|
||||
return this.expiryTime / -86400000;
|
||||
}
|
||||
return moment(this.expiryTime);
|
||||
}
|
||||
|
||||
set _expiryTime(t) {
|
||||
if (t == null || t === "") {
|
||||
this.expiryTime = 0;
|
||||
} else {
|
||||
this.expiryTime = t.valueOf();
|
||||
}
|
||||
}
|
||||
get _totalGB() {
|
||||
return NumberFormatter.toFixed(this.totalGB / SizeFormatter.ONE_GB, 2);
|
||||
}
|
||||
|
||||
set _totalGB(gb) {
|
||||
this.totalGB = NumberFormatter.toFixed(gb * SizeFormatter.ONE_GB, 0);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
Inbound.TrojanSettings.Fallback = class extends XrayCommonClass {
|
||||
@@ -2343,7 +2420,7 @@ Inbound.ShadowsocksSettings = class extends Inbound.Settings {
|
||||
json.method,
|
||||
json.password,
|
||||
json.network,
|
||||
json.clients.map(client => Inbound.ShadowsocksSettings.Shadowsocks.fromJson(client)),
|
||||
(json.clients || []).map(client => Inbound.ShadowsocksSettings.Shadowsocks.fromJson(client)),
|
||||
json.ivCheck,
|
||||
);
|
||||
}
|
||||
@@ -2359,53 +2436,22 @@ Inbound.ShadowsocksSettings = class extends Inbound.Settings {
|
||||
}
|
||||
};
|
||||
|
||||
Inbound.ShadowsocksSettings.Shadowsocks = class extends XrayCommonClass {
|
||||
Inbound.ShadowsocksSettings.Shadowsocks = class extends Inbound.ClientBase {
|
||||
constructor(
|
||||
method = '',
|
||||
password = RandomUtil.randomShadowsocksPassword(),
|
||||
email = RandomUtil.randomLowerAndNum(8),
|
||||
limitIp = 0,
|
||||
totalGB = 0,
|
||||
expiryTime = 0,
|
||||
enable = true,
|
||||
tgId = '',
|
||||
subId = RandomUtil.randomLowerAndNum(16),
|
||||
comment = '',
|
||||
reset = 0,
|
||||
created_at = undefined,
|
||||
updated_at = undefined
|
||||
email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at,
|
||||
) {
|
||||
super();
|
||||
super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at);
|
||||
this.method = method;
|
||||
this.password = password;
|
||||
this.email = email;
|
||||
this.limitIp = limitIp;
|
||||
this.totalGB = totalGB;
|
||||
this.expiryTime = expiryTime;
|
||||
this.enable = enable;
|
||||
this.tgId = tgId;
|
||||
this.subId = subId;
|
||||
this.comment = comment;
|
||||
this.reset = reset;
|
||||
this.created_at = created_at;
|
||||
this.updated_at = updated_at;
|
||||
}
|
||||
|
||||
toJson() {
|
||||
return {
|
||||
method: this.method,
|
||||
password: this.password,
|
||||
email: this.email,
|
||||
limitIp: this.limitIp,
|
||||
totalGB: this.totalGB,
|
||||
expiryTime: this.expiryTime,
|
||||
enable: this.enable,
|
||||
tgId: this.tgId,
|
||||
subId: this.subId,
|
||||
comment: this.comment,
|
||||
reset: this.reset,
|
||||
created_at: this.created_at,
|
||||
updated_at: this.updated_at,
|
||||
...this._clientBaseToJson(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2413,45 +2459,56 @@ Inbound.ShadowsocksSettings.Shadowsocks = class extends XrayCommonClass {
|
||||
return new Inbound.ShadowsocksSettings.Shadowsocks(
|
||||
json.method,
|
||||
json.password,
|
||||
json.email,
|
||||
json.limitIp,
|
||||
json.totalGB,
|
||||
json.expiryTime,
|
||||
json.enable,
|
||||
json.tgId,
|
||||
json.subId,
|
||||
json.comment,
|
||||
json.reset,
|
||||
json.created_at,
|
||||
json.updated_at,
|
||||
...Inbound.ClientBase.commonArgsFromJson(json),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Inbound.HysteriaSettings = class extends Inbound.Settings {
|
||||
constructor(protocol, version = 2, hysterias = [new Inbound.HysteriaSettings.Hysteria()]) {
|
||||
super(protocol);
|
||||
this.version = version;
|
||||
this.hysterias = hysterias;
|
||||
}
|
||||
|
||||
static fromJson(json = {}) {
|
||||
return new Inbound.HysteriaSettings(
|
||||
Protocols.HYSTERIA,
|
||||
json.version ?? 2,
|
||||
(json.clients || []).map(client => Inbound.HysteriaSettings.Hysteria.fromJson(client)),
|
||||
);
|
||||
}
|
||||
|
||||
get _expiryTime() {
|
||||
if (this.expiryTime === 0 || this.expiryTime === "") {
|
||||
return null;
|
||||
}
|
||||
if (this.expiryTime < 0) {
|
||||
return this.expiryTime / -86400000;
|
||||
}
|
||||
return moment(this.expiryTime);
|
||||
toJson() {
|
||||
return {
|
||||
version: this.version,
|
||||
clients: Inbound.HysteriaSettings.toJsonArray(this.hysterias),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
Inbound.HysteriaSettings.Hysteria = class extends Inbound.ClientBase {
|
||||
constructor(
|
||||
auth = RandomUtil.randomSeq(10),
|
||||
email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at,
|
||||
) {
|
||||
super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at);
|
||||
this.auth = auth;
|
||||
}
|
||||
|
||||
set _expiryTime(t) {
|
||||
if (t == null || t === "") {
|
||||
this.expiryTime = 0;
|
||||
} else {
|
||||
this.expiryTime = t.valueOf();
|
||||
}
|
||||
}
|
||||
get _totalGB() {
|
||||
return NumberFormatter.toFixed(this.totalGB / SizeFormatter.ONE_GB, 2);
|
||||
toJson() {
|
||||
return {
|
||||
auth: this.auth,
|
||||
...this._clientBaseToJson(),
|
||||
};
|
||||
}
|
||||
|
||||
set _totalGB(gb) {
|
||||
this.totalGB = NumberFormatter.toFixed(gb * SizeFormatter.ONE_GB, 0);
|
||||
static fromJson(json = {}) {
|
||||
return new Inbound.HysteriaSettings.Hysteria(
|
||||
json.auth,
|
||||
...Inbound.ClientBase.commonArgsFromJson(json),
|
||||
);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
Inbound.TunnelSettings = class extends Inbound.Settings {
|
||||
@@ -2682,29 +2739,60 @@ Inbound.TunSettings = class extends Inbound.Settings {
|
||||
constructor(
|
||||
protocol,
|
||||
name = 'xray0',
|
||||
mtu = 1500,
|
||||
userLevel = 0
|
||||
mtu = [1500, 1280],
|
||||
gateway = [],
|
||||
dns = [],
|
||||
userLevel = 0,
|
||||
autoSystemRoutingTable = [],
|
||||
autoOutboundsInterface = 'auto'
|
||||
) {
|
||||
super(protocol);
|
||||
this.name = name;
|
||||
this.mtu = mtu;
|
||||
this.mtu = this._normalizeMtu(mtu);
|
||||
this.gateway = Array.isArray(gateway) ? gateway : [];
|
||||
this.dns = Array.isArray(dns) ? dns : [];
|
||||
this.userLevel = userLevel;
|
||||
this.autoSystemRoutingTable = Array.isArray(autoSystemRoutingTable) ? autoSystemRoutingTable : [];
|
||||
this.autoOutboundsInterface = autoOutboundsInterface;
|
||||
}
|
||||
|
||||
_normalizeMtu(mtu) {
|
||||
if (!Array.isArray(mtu)) {
|
||||
const single = Number(mtu) || 1500;
|
||||
return [single, single];
|
||||
}
|
||||
if (mtu.length === 0) {
|
||||
return [1500, 1280];
|
||||
}
|
||||
if (mtu.length === 1) {
|
||||
const single = Number(mtu[0]) || 1500;
|
||||
return [single, single];
|
||||
}
|
||||
return [Number(mtu[0]) || 1500, Number(mtu[1]) || 1280];
|
||||
}
|
||||
|
||||
static fromJson(json = {}) {
|
||||
return new Inbound.TunSettings(
|
||||
Protocols.TUN,
|
||||
json.name ?? 'xray0',
|
||||
json.mtu ?? json.MTU ?? 1500,
|
||||
json.userLevel ?? 0
|
||||
json.mtu ?? json.MTU ?? [1500, 1280],
|
||||
json.gateway ?? json.Gateway ?? [],
|
||||
json.dns ?? json.DNS ?? [],
|
||||
json.userLevel ?? 0,
|
||||
json.autoSystemRoutingTable ?? [],
|
||||
Object.prototype.hasOwnProperty.call(json, 'autoOutboundsInterface') ? json.autoOutboundsInterface : 'auto'
|
||||
);
|
||||
}
|
||||
|
||||
toJson() {
|
||||
return {
|
||||
name: this.name || 'xray0',
|
||||
mtu: this.mtu || 1500,
|
||||
mtu: this._normalizeMtu(this.mtu),
|
||||
gateway: this.gateway,
|
||||
dns: this.dns,
|
||||
userLevel: this.userLevel || 0,
|
||||
autoSystemRoutingTable: this.autoSystemRoutingTable,
|
||||
autoOutboundsInterface: this.autoOutboundsInterface,
|
||||
};
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -169,18 +169,16 @@ class KcpStreamSettings extends CommonClass {
|
||||
tti = 20,
|
||||
uplinkCapacity = 5,
|
||||
downlinkCapacity = 20,
|
||||
congestion = false,
|
||||
readBufferSize = 1,
|
||||
writeBufferSize = 1,
|
||||
cwndMultiplier = 0,
|
||||
maxSendingWindow = 0,
|
||||
) {
|
||||
super();
|
||||
this.mtu = mtu;
|
||||
this.tti = tti;
|
||||
this.upCap = uplinkCapacity;
|
||||
this.downCap = downlinkCapacity;
|
||||
this.congestion = congestion;
|
||||
this.readBuffer = readBufferSize;
|
||||
this.writeBuffer = writeBufferSize;
|
||||
this.cwndMultiplier = cwndMultiplier;
|
||||
this.maxSendingWindow = maxSendingWindow;
|
||||
}
|
||||
|
||||
static fromJson(json = {}) {
|
||||
@@ -189,9 +187,8 @@ class KcpStreamSettings extends CommonClass {
|
||||
json.tti,
|
||||
json.uplinkCapacity,
|
||||
json.downlinkCapacity,
|
||||
json.congestion,
|
||||
json.readBufferSize,
|
||||
json.writeBufferSize,
|
||||
json.cwndMultiplier,
|
||||
json.maxSendingWindow,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -201,9 +198,8 @@ class KcpStreamSettings extends CommonClass {
|
||||
tti: this.tti,
|
||||
uplinkCapacity: this.upCap,
|
||||
downlinkCapacity: this.downCap,
|
||||
congestion: this.congestion,
|
||||
readBufferSize: this.readBuffer,
|
||||
writeBufferSize: this.writeBuffer,
|
||||
cwndMultiplier: this.cwndMultiplier,
|
||||
maxSendingWindow: this.maxSendingWindow,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -577,8 +573,11 @@ class UdpMask extends CommonClass {
|
||||
case 'mkcp-aes128gcm':
|
||||
return { password: settings.password || '' };
|
||||
case 'header-dns':
|
||||
case 'xdns':
|
||||
return { domain: settings.domain || '' };
|
||||
case 'xdns':
|
||||
return { resolvers: Array.isArray(settings.resolvers) ? settings.resolvers : [] };
|
||||
case 'xicmp':
|
||||
return { ip: settings.ip || '', id: settings.id ?? 0 };
|
||||
case 'mkcp-original':
|
||||
case 'header-dtls':
|
||||
case 'header-srtp':
|
||||
@@ -586,6 +585,12 @@ class UdpMask extends CommonClass {
|
||||
case 'header-wechat':
|
||||
case 'header-wireguard':
|
||||
return {}; // No settings needed
|
||||
case 'header-custom':
|
||||
return { client: [], server: [] };
|
||||
case 'noise':
|
||||
return { reset: 0, noise: [] };
|
||||
case 'sudoku':
|
||||
return { ascii: '', customTable: '', customTables: [], paddingMin: 0, paddingMax: 0 };
|
||||
default:
|
||||
return settings;
|
||||
}
|
||||
@@ -782,8 +787,8 @@ class Outbound extends CommonClass {
|
||||
}
|
||||
|
||||
canEnableTls() {
|
||||
if (![Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks, Protocols.Hysteria].includes(this.protocol)) return false;
|
||||
if (this.protocol === Protocols.Hysteria) return this.stream.network === 'hysteria';
|
||||
if (this.protocol === Protocols.Hysteria) return true;
|
||||
if (![Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(this.protocol)) return false;
|
||||
return ["tcp", "ws", "http", "grpc", "httpupgrade", "xhttp"].includes(this.stream.network);
|
||||
}
|
||||
|
||||
@@ -1133,13 +1138,15 @@ Outbound.FreedomSettings = class extends CommonClass {
|
||||
domainStrategy = '',
|
||||
redirect = '',
|
||||
fragment = {},
|
||||
noises = []
|
||||
noises = [],
|
||||
ipsBlocked = [],
|
||||
) {
|
||||
super();
|
||||
this.domainStrategy = domainStrategy;
|
||||
this.redirect = redirect;
|
||||
this.fragment = fragment;
|
||||
this.noises = noises;
|
||||
this.fragment = fragment || {};
|
||||
this.noises = Array.isArray(noises) ? noises : [];
|
||||
this.ipsBlocked = Array.isArray(ipsBlocked) ? ipsBlocked : [];
|
||||
}
|
||||
|
||||
addNoise() {
|
||||
@@ -1154,8 +1161,9 @@ Outbound.FreedomSettings = class extends CommonClass {
|
||||
return new Outbound.FreedomSettings(
|
||||
json.domainStrategy,
|
||||
json.redirect,
|
||||
json.fragment ? Outbound.FreedomSettings.Fragment.fromJson(json.fragment) : undefined,
|
||||
json.noises ? json.noises.map(noise => Outbound.FreedomSettings.Noise.fromJson(noise)) : undefined,
|
||||
json.fragment ? Outbound.FreedomSettings.Fragment.fromJson(json.fragment) : {},
|
||||
json.noises ? json.noises.map(noise => Outbound.FreedomSettings.Noise.fromJson(noise)) : [],
|
||||
json.ipsBlocked || [],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1165,6 +1173,7 @@ Outbound.FreedomSettings = class extends CommonClass {
|
||||
redirect: ObjectUtil.isEmpty(this.redirect) ? undefined : this.redirect,
|
||||
fragment: Object.keys(this.fragment).length === 0 ? undefined : this.fragment,
|
||||
noises: this.noises.length === 0 ? undefined : Outbound.FreedomSettings.Noise.toJsonArray(this.noises),
|
||||
ipsBlocked: this.ipsBlocked.length === 0 ? undefined : this.ipsBlocked,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -38,6 +38,8 @@ class AllSetting {
|
||||
this.subPort = 2096;
|
||||
this.subPath = "/sub/";
|
||||
this.subJsonPath = "/json/";
|
||||
this.subClashEnable = true;
|
||||
this.subClashPath = "/clash/";
|
||||
this.subDomain = "";
|
||||
this.externalTrafficInformEnable = false;
|
||||
this.externalTrafficInformURI = "";
|
||||
@@ -48,6 +50,7 @@ class AllSetting {
|
||||
this.subShowInfo = true;
|
||||
this.subURI = "";
|
||||
this.subJsonURI = "";
|
||||
this.subClashURI = "";
|
||||
this.subJsonFragment = "";
|
||||
this.subJsonNoises = "";
|
||||
this.subJsonMux = "";
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
sId: el.getAttribute('data-sid') || '',
|
||||
subUrl: el.getAttribute('data-sub-url') || '',
|
||||
subJsonUrl: el.getAttribute('data-subjson-url') || '',
|
||||
subClashUrl: el.getAttribute('data-subclash-url') || '',
|
||||
download: el.getAttribute('data-download') || '',
|
||||
upload: el.getAttribute('data-upload') || '',
|
||||
used: el.getAttribute('data-used') || '',
|
||||
@@ -98,13 +99,19 @@
|
||||
this.lang = LanguageManager.getLanguage();
|
||||
const tpl = document.getElementById('subscription-data');
|
||||
const sj = tpl ? tpl.getAttribute('data-subjson-url') : '';
|
||||
const sc = tpl ? tpl.getAttribute('data-subclash-url') : '';
|
||||
if (sj) this.app.subJsonUrl = sj;
|
||||
if (sc) this.app.subClashUrl = sc;
|
||||
drawQR(this.app.subUrl);
|
||||
try {
|
||||
const elJson = document.getElementById('qrcode-subjson');
|
||||
if (elJson && this.app.subJsonUrl) {
|
||||
new QRious({ element: elJson, value: this.app.subJsonUrl, size: 220 });
|
||||
}
|
||||
const elClash = document.getElementById('qrcode-subclash');
|
||||
if (elClash && this.app.subClashUrl) {
|
||||
new QRious({ element: elClash, value: this.app.subClashUrl, size: 220 });
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
this._onResize = () => { this.viewportWidth = window.innerWidth; };
|
||||
window.addEventListener('resize', this._onResize);
|
||||
|
||||
@@ -651,10 +651,13 @@ class CookieManager {
|
||||
}
|
||||
|
||||
static setCookie(cname, cvalue, exdays) {
|
||||
const d = new Date();
|
||||
d.setTime(d.getTime() + exdays * 24 * 60 * 60 * 1000);
|
||||
let expires = 'expires=' + d.toUTCString();
|
||||
document.cookie = cname + '=' + encodeURIComponent(cvalue) + ';' + expires + ';path=/';
|
||||
let expires = '';
|
||||
if (exdays) {
|
||||
const d = new Date();
|
||||
d.setTime(d.getTime() + exdays * 24 * 60 * 60 * 1000);
|
||||
expires = 'expires=' + d.toUTCString() + ';';
|
||||
}
|
||||
document.cookie = cname + '=' + encodeURIComponent(cvalue) + ';' + expires + 'path=/';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -813,13 +816,13 @@ class LanguageManager {
|
||||
});
|
||||
|
||||
if (LanguageManager.isSupportLanguage(lang)) {
|
||||
CookieManager.setCookie("lang", lang, 150);
|
||||
CookieManager.setCookie("lang", lang);
|
||||
} else {
|
||||
CookieManager.setCookie("lang", "en-US", 150);
|
||||
CookieManager.setCookie("lang", "en-US");
|
||||
window.location.reload();
|
||||
}
|
||||
} else {
|
||||
CookieManager.setCookie("lang", "en-US", 150);
|
||||
CookieManager.setCookie("lang", "en-US");
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
@@ -832,7 +835,7 @@ class LanguageManager {
|
||||
language = "en-US";
|
||||
}
|
||||
|
||||
CookieManager.setCookie("lang", language, 150);
|
||||
CookieManager.setCookie("lang", language);
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
|
||||
@@ -18,9 +18,9 @@ type APIController struct {
|
||||
}
|
||||
|
||||
// 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.initRouter(g)
|
||||
a.initRouter(g, customGeo)
|
||||
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.
|
||||
func (a *APIController) initRouter(g *gin.RouterGroup) {
|
||||
func (a *APIController) initRouter(g *gin.RouterGroup, customGeo *service.CustomGeoService) {
|
||||
// Main API group
|
||||
api := g.Group("/panel/api")
|
||||
api.Use(a.checkAPIAuth)
|
||||
@@ -48,6 +48,8 @@ func (a *APIController) initRouter(g *gin.RouterGroup) {
|
||||
server := api.Group("/server")
|
||||
a.serverController = NewServerController(server)
|
||||
|
||||
NewCustomGeoController(api.Group("/custom-geo"), customGeo)
|
||||
|
||||
// Extra routes
|
||||
api.GET("/backuptotgbot", a.BackuptoTgbot)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
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"))
|
||||
case errors.Is(err, service.ErrCustomGeoSSRFBlocked):
|
||||
logger.Warning("custom geo SSRF blocked:", err)
|
||||
return errors.New(I18nWeb(c, "pages.index.customGeoErrUrlHost"))
|
||||
case errors.Is(err, service.ErrCustomGeoPathTraversal):
|
||||
logger.Warning("custom geo path traversal blocked:", 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)
|
||||
}
|
||||
+11
-8
@@ -1,6 +1,7 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"text/template"
|
||||
"time"
|
||||
@@ -71,14 +72,22 @@ func (a *IndexController) login(c *gin.Context) {
|
||||
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")
|
||||
safeUser := template.HTMLEscapeString(form.Username)
|
||||
safePass := template.HTMLEscapeString(form.Password)
|
||||
|
||||
if user == nil {
|
||||
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"))
|
||||
return
|
||||
}
|
||||
@@ -86,12 +95,6 @@ func (a *IndexController) login(c *gin.Context) {
|
||||
logger.Infof("%s logged in successfully, Ip Address: %s\n", safeUser, getRemoteIp(c))
|
||||
a.tgbot.UserLoginNotify(safeUser, ``, getRemoteIp(c), timeStr, 1)
|
||||
|
||||
sessionMaxAge, err := a.settingService.GetSessionMaxAge()
|
||||
if err != nil {
|
||||
logger.Warning("Unable to get session's max age from DB")
|
||||
}
|
||||
|
||||
session.SetMaxAge(c, sessionMaxAge*60)
|
||||
session.SetLoginUser(c, user)
|
||||
if err := sessions.Default(c).Save(); err != nil {
|
||||
logger.Warning("Unable to save session: ", err)
|
||||
|
||||
+11
-2
@@ -50,8 +50,17 @@ func jsonMsgObj(c *gin.Context, msg string, obj any, err error) {
|
||||
}
|
||||
} else {
|
||||
m.Success = false
|
||||
m.Msg = msg + " (" + err.Error() + ")"
|
||||
logger.Warning(msg+" "+I18nWeb(c, "fail")+": ", err)
|
||||
errStr := err.Error()
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -30,8 +30,10 @@ const (
|
||||
)
|
||||
|
||||
var upgrader = ws.Upgrader{
|
||||
ReadBufferSize: 4096, // Increased from 1024 for better performance
|
||||
WriteBufferSize: 4096, // Increased from 1024 for better performance
|
||||
ReadBufferSize: 32768,
|
||||
WriteBufferSize: 32768,
|
||||
EnableCompression: true, // Negotiate permessage-deflate compression if the client supports it
|
||||
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
// Check origin for security
|
||||
origin := r.Header.Get("Origin")
|
||||
|
||||
@@ -17,6 +17,7 @@ type XraySettingController struct {
|
||||
OutboundService service.OutboundService
|
||||
XrayService service.XrayService
|
||||
WarpService service.WarpService
|
||||
NordService service.NordService
|
||||
}
|
||||
|
||||
// NewXraySettingController creates a new XraySettingController and initializes its routes.
|
||||
@@ -35,6 +36,7 @@ func (a *XraySettingController) initRouter(g *gin.RouterGroup) {
|
||||
|
||||
g.POST("/", a.getXraySetting)
|
||||
g.POST("/warp/:action", a.warp)
|
||||
g.POST("/nord/:action", a.nord)
|
||||
g.POST("/update", a.updateSetting)
|
||||
g.POST("/resetOutboundsTraffic", a.resetOutboundsTraffic)
|
||||
g.POST("/testOutbound", a.testOutbound)
|
||||
@@ -123,6 +125,32 @@ func (a *XraySettingController) warp(c *gin.Context) {
|
||||
jsonObj(c, resp, err)
|
||||
}
|
||||
|
||||
// nord handles NordVPN-related operations based on the action parameter.
|
||||
func (a *XraySettingController) nord(c *gin.Context) {
|
||||
action := c.Param("action")
|
||||
var resp string
|
||||
var err error
|
||||
switch action {
|
||||
case "countries":
|
||||
resp, err = a.NordService.GetCountries()
|
||||
case "servers":
|
||||
countryId := c.PostForm("countryId")
|
||||
resp, err = a.NordService.GetServers(countryId)
|
||||
case "reg":
|
||||
token := c.PostForm("token")
|
||||
resp, err = a.NordService.GetCredentials(token)
|
||||
case "setKey":
|
||||
key := c.PostForm("key")
|
||||
resp, err = a.NordService.SetKey(key)
|
||||
case "data":
|
||||
resp, err = a.NordService.GetNordData()
|
||||
case "del":
|
||||
err = a.NordService.DelNordData()
|
||||
}
|
||||
|
||||
jsonObj(c, resp, err)
|
||||
}
|
||||
|
||||
// getOutboundsTraffic retrieves the traffic statistics for outbounds.
|
||||
func (a *XraySettingController) getOutboundsTraffic(c *gin.Context) {
|
||||
outboundsTraffic, err := a.OutboundService.GetOutboundsTraffic()
|
||||
|
||||
@@ -76,6 +76,9 @@ type AllSetting struct {
|
||||
SubURI string `json:"subURI" form:"subURI"` // Subscription server URI
|
||||
SubJsonPath string `json:"subJsonPath" form:"subJsonPath"` // Path for JSON subscription endpoint
|
||||
SubJsonURI string `json:"subJsonURI" form:"subJsonURI"` // JSON subscription server URI
|
||||
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
|
||||
SubJsonNoises string `json:"subJsonNoises" form:"subJsonNoises"` // JSON subscription noise configuration
|
||||
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration
|
||||
@@ -168,6 +171,13 @@ func (s *AllSetting) CheckValid() error {
|
||||
s.SubJsonPath += "/"
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(s.SubClashPath, "/") {
|
||||
s.SubClashPath = "/" + s.SubClashPath
|
||||
}
|
||||
if !strings.HasSuffix(s.SubClashPath, "/") {
|
||||
s.SubClashPath += "/"
|
||||
}
|
||||
|
||||
_, err := time.LoadLocation(s.TimeLocation)
|
||||
if err != nil {
|
||||
return common.NewError("time location not exist:", s.TimeLocation)
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<template slot="enable" slot-scope="text, client, index">
|
||||
<a-switch v-model="client.enable" @change="switchEnableClient(record.id,client)"></a-switch>
|
||||
<a-switch v-model="client.enable" @change="switchEnableClient(record.id, client, $event)"></a-switch>
|
||||
</template>
|
||||
<template slot="online" slot-scope="text, client, index">
|
||||
<a-popover :overlay-class-name="themeSwitcher.currentTheme">
|
||||
@@ -165,7 +165,7 @@
|
||||
<span :style="{ color: '#FF4D4F' }"> {{ i18n "delete"}}</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item>
|
||||
<a-switch v-model="client.enable" size="small" @change="switchEnableClient(record.id,client)"></a-switch>
|
||||
<a-switch v-model="client.enable" size="small" @change="switchEnableClient(record.id, client, $event)"></a-switch>
|
||||
{{ i18n "enable"}}
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
|
||||
+245
-161
@@ -1,172 +1,256 @@
|
||||
{{define "form/client"}}
|
||||
<a-form layout="horizontal" v-if="client" :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-form-item label='{{ i18n "pages.inbounds.enable" }}'>
|
||||
<a-switch v-model="client.enable"></a-switch>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<span>{{ i18n "pages.inbounds.emailDesc" }}</span>
|
||||
</template>
|
||||
{{ i18n "pages.inbounds.email" }}
|
||||
<a-icon type="sync" @click="client.email = RandomUtil.randomLowerAndNum(9)"></a-icon>
|
||||
</a-tooltip>
|
||||
<a-form
|
||||
layout="horizontal"
|
||||
v-if="client"
|
||||
:colon="false"
|
||||
:label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }"
|
||||
>
|
||||
<a-form-item label='{{ i18n "pages.inbounds.enable" }}'>
|
||||
<a-switch v-model="client.enable"></a-switch>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<span>{{ i18n "pages.inbounds.emailDesc" }}</span>
|
||||
</template>
|
||||
<a-input v-model.trim="client.email"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="inbound.protocol === Protocols.TROJAN || inbound.protocol === Protocols.SHADOWSOCKS">
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<span>{{ i18n "reset" }}</span>
|
||||
</template>
|
||||
{{ i18n "password" }}
|
||||
<a-icon v-if="inbound.protocol === Protocols.SHADOWSOCKS" @click="client.password = RandomUtil.randomShadowsocksPassword(inbound.settings.method)" type="sync"></a-icon>
|
||||
<a-icon v-if="inbound.protocol === Protocols.TROJAN" @click="client.password = RandomUtil.randomSeq(10)"type="sync"> </a-icon>
|
||||
</a-tooltip>
|
||||
{{ i18n "pages.inbounds.email" }}
|
||||
<a-icon
|
||||
type="sync"
|
||||
@click="client.email = RandomUtil.randomLowerAndNum(9)"
|
||||
></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input v-model.trim="client.email"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
v-if="inbound.protocol === Protocols.TROJAN || inbound.protocol === Protocols.SHADOWSOCKS"
|
||||
>
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<span>{{ i18n "reset" }}</span>
|
||||
</template>
|
||||
<a-input v-model.trim="client.password"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="inbound.protocol === Protocols.VMESS || inbound.protocol === Protocols.VLESS">
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<span>{{ i18n "reset" }}</span>
|
||||
</template>
|
||||
ID <a-icon @click="client.id = RandomUtil.randomUUID()" type="sync"></a-icon>
|
||||
</a-tooltip>
|
||||
{{ i18n "password" }}
|
||||
<a-icon
|
||||
v-if="inbound.protocol === Protocols.SHADOWSOCKS"
|
||||
@click="client.password = RandomUtil.randomShadowsocksPassword(inbound.settings.method)"
|
||||
type="sync"
|
||||
></a-icon>
|
||||
<a-icon
|
||||
v-if="inbound.protocol === Protocols.TROJAN"
|
||||
@click="client.password = RandomUtil.randomSeq(10)"
|
||||
type="sync"
|
||||
>
|
||||
</a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input v-model.trim="client.password"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="inbound.protocol === Protocols.HYSTERIA">
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<span>{{ i18n "reset" }}</span>
|
||||
</template>
|
||||
<a-input v-model.trim="client.id"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="inbound.protocol === Protocols.VMESS" label='{{ i18n "security" }}'>
|
||||
<a-select v-model="client.security" :dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option v-for="key in USERS_SECURITY" :value="key">[[ key ]]</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="client.email && app.subSettings?.enable">
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<span>{{ i18n "pages.inbounds.subscriptionDesc" }}</span>
|
||||
</template>
|
||||
Subscription
|
||||
<a-icon @click="client.subId = RandomUtil.randomLowerAndNum(16)" type="sync"></a-icon>
|
||||
</a-tooltip>
|
||||
Auth Password
|
||||
<a-icon
|
||||
@click="client.auth = RandomUtil.randomSeq(10)"
|
||||
type="sync"
|
||||
></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input v-model.trim="client.auth"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
v-if="inbound.protocol === Protocols.VMESS || inbound.protocol === Protocols.VLESS"
|
||||
>
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<span>{{ i18n "reset" }}</span>
|
||||
</template>
|
||||
<a-input v-model.trim="client.subId"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="client.email && app.tgBotEnable">
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<span>{{ i18n "pages.inbounds.telegramDesc" }}</span>
|
||||
</template>
|
||||
Telegram ChatID
|
||||
<a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
ID
|
||||
<a-icon
|
||||
@click="client.id = RandomUtil.randomUUID()"
|
||||
type="sync"
|
||||
></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input v-model.trim="client.id"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
v-if="inbound.protocol === Protocols.VMESS"
|
||||
label='{{ i18n "security" }}'
|
||||
>
|
||||
<a-select
|
||||
v-model="client.security"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
>
|
||||
<a-select-option v-for="key in USERS_SECURITY" :value="key"
|
||||
>[[ key ]]</a-select-option
|
||||
>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="client.email && app.subSettings?.enable">
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<span>{{ i18n "pages.inbounds.subscriptionDesc" }}</span>
|
||||
</template>
|
||||
<a-input-number :style="{ width: '50%' }" v-model.number="client.tgId" min="0"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="client.email" label='{{ i18n "comment" }}'>
|
||||
<a-input v-model.trim="client.comment"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="app.ipLimitEnable">
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<span>{{ i18n "pages.inbounds.IPLimitDesc"}}</span>
|
||||
</template>
|
||||
<span>{{ i18n "pages.inbounds.IPLimit"}} </span>
|
||||
<a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
Subscription
|
||||
<a-icon
|
||||
@click="client.subId = RandomUtil.randomLowerAndNum(16)"
|
||||
type="sync"
|
||||
></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input v-model.trim="client.subId"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="client.email && app.tgBotEnable">
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<span>{{ i18n "pages.inbounds.telegramDesc" }}</span>
|
||||
</template>
|
||||
<a-input-number v-model.number="client.limitIp" min="0"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="app.ipLimitEnable && client.limitIp > 0 && client.email && isEdit">
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<span>{{ i18n "pages.inbounds.IPLimitlogDesc" }}</span>
|
||||
</template>
|
||||
<span>{{ i18n "pages.inbounds.IPLimitlog" }} </span>
|
||||
<a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
Telegram ChatID
|
||||
<a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input-number
|
||||
:style="{ width: '50%' }"
|
||||
v-model.number="client.tgId"
|
||||
min="0"
|
||||
></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="client.email" label='{{ i18n "comment" }}'>
|
||||
<a-input v-model.trim="client.comment"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="app.ipLimitEnable">
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<span>{{ i18n "pages.inbounds.IPLimitDesc"}}</span>
|
||||
</template>
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<span>{{ i18n "pages.inbounds.IPLimitlogclear" }}</span>
|
||||
</template>
|
||||
<span :style="{ color: '#FF4D4F' }">
|
||||
<a-icon type="delete" @click="clearDBClientIps(client.email)"></a-icon>
|
||||
</span>
|
||||
</a-tooltip>
|
||||
<a-form layout="block">
|
||||
<a-textarea id="clientIPs" readonly @click="getDBClientIps(client.email)" placeholder="Click To Get IPs"
|
||||
:auto-size="{ minRows: 5, maxRows: 10 }">
|
||||
</a-textarea>
|
||||
</a-form>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="inbound.canEnableTlsFlow()" label='Flow'>
|
||||
<a-select v-model="client.flow" :dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option value="" selected>{{ i18n "none" }}</a-select-option>
|
||||
<a-select-option v-for="key in TLS_FLOW_CONTROL" :value="key">[[ key ]]</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
0 <span>{{ i18n "pages.inbounds.meansNoLimit" }}</span>
|
||||
</template>
|
||||
{{ i18n "pages.inbounds.totalFlow" }}
|
||||
<a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
<span>{{ i18n "pages.inbounds.IPLimit"}} </span>
|
||||
<a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input-number v-model.number="client.limitIp" min="0"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
v-if="app.ipLimitEnable && client.limitIp > 0 && client.email && isEdit"
|
||||
>
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<span>{{ i18n "pages.inbounds.IPLimitlogDesc" }}</span>
|
||||
</template>
|
||||
<a-input-number v-model.number="client._totalGB" :min="0"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="isEdit && clientStats" label='{{ i18n "usage" }}'>
|
||||
<a-tag :color="ColorUtils.clientUsageColor(clientStats, app.trafficDiff)">
|
||||
[[ SizeFormatter.sizeFormat(clientStats.up) ]] /
|
||||
[[ SizeFormatter.sizeFormat(clientStats.down) ]]
|
||||
([[ SizeFormatter.sizeFormat(clientStats.up + clientStats.down) ]])
|
||||
</a-tag>
|
||||
<a-tooltip>
|
||||
<template slot="title">{{ i18n "pages.inbounds.resetTraffic" }}</template>
|
||||
<a-icon type="retweet"
|
||||
@click="resetClientTraffic(client.email,clientStats.inboundId,$event.target)"
|
||||
v-if="client.email.length > 0"></a-icon>
|
||||
</a-tooltip>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.client.delayedStart" }}'>
|
||||
<a-switch v-model="delayedStart" @click="client._expiryTime=0"></a-switch>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="delayedStart" label='{{ i18n "pages.client.expireDays" }}'>
|
||||
<a-input-number v-model.number="delayedExpireDays" :min="0"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item v-else>
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
<template slot="title">{{ i18n "pages.inbounds.leaveBlankToNeverExpire" }}</template>
|
||||
{{ i18n "pages.inbounds.expireDate" }}
|
||||
<a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
<span>{{ i18n "pages.inbounds.IPLimitlog" }} </span>
|
||||
<a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<span>{{ i18n "pages.inbounds.IPLimitlogclear" }}</span>
|
||||
</template>
|
||||
<span :style="{ color: '#FF4D4F' }">
|
||||
<a-icon type="delete" @click="clearDBClientIps(client.email)"></a-icon>
|
||||
</span>
|
||||
</a-tooltip>
|
||||
<a-form layout="block">
|
||||
<a-textarea
|
||||
id="clientIPs"
|
||||
readonly
|
||||
@click="getDBClientIps(client.email)"
|
||||
placeholder="Click To Get IPs"
|
||||
:auto-size="{ minRows: 5, maxRows: 10 }"
|
||||
>
|
||||
</a-textarea>
|
||||
</a-form>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="inbound.canEnableTlsFlow()" label="Flow">
|
||||
<a-select
|
||||
v-model="client.flow"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
>
|
||||
<a-select-option value selected>{{ i18n "none" }}</a-select-option>
|
||||
<a-select-option v-for="key in TLS_FLOW_CONTROL" :value="key"
|
||||
>[[ key ]]</a-select-option
|
||||
>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
0 <span>{{ i18n "pages.inbounds.meansNoLimit" }}</span>
|
||||
</template>
|
||||
<a-date-picker v-if="datepicker == 'gregorian'" :show-time="{ format: 'HH:mm:ss' }" format="YYYY-MM-DD HH:mm:ss"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme" v-model="client._expiryTime"></a-date-picker>
|
||||
<a-persian-datepicker v-else placeholder='{{ i18n "pages.settings.datepickerPlaceholder" }}'
|
||||
value="client._expiryTime" v-model="client._expiryTime"></a-persian-datepicker>
|
||||
<a-tag color="red" v-if="isEdit && isExpiry">Expired</a-tag>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="client.expiryTime != 0">
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
<template slot="title">{{ i18n "pages.client.renewDesc" }}</template>
|
||||
{{ i18n "pages.client.renew" }}
|
||||
<a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input-number v-model.number="client.reset" :min="0"></a-input-number>
|
||||
</a-form-item>
|
||||
{{ i18n "pages.inbounds.totalFlow" }}
|
||||
<a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input-number v-model.number="client._totalGB" :min="0"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="isEdit && clientStats" label='{{ i18n "usage" }}'>
|
||||
<a-tag :color="ColorUtils.clientUsageColor(clientStats, app.trafficDiff)">
|
||||
[[ SizeFormatter.sizeFormat(clientStats.up) ]] / [[
|
||||
SizeFormatter.sizeFormat(clientStats.down) ]] ([[
|
||||
SizeFormatter.sizeFormat(clientStats.up + clientStats.down) ]])
|
||||
</a-tag>
|
||||
<a-tooltip>
|
||||
<template slot="title">{{ i18n "pages.inbounds.resetTraffic" }}</template>
|
||||
<a-icon
|
||||
type="retweet"
|
||||
@click="resetClientTraffic(client.email,clientStats.inboundId,$event.target)"
|
||||
v-if="client.email.length > 0"
|
||||
></a-icon>
|
||||
</a-tooltip>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.client.delayedStart" }}'>
|
||||
<a-switch v-model="delayedStart" @click="client._expiryTime=0"></a-switch>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="delayedStart" label='{{ i18n "pages.client.expireDays" }}'>
|
||||
<a-input-number
|
||||
v-model.number="delayedExpireDays"
|
||||
:min="0"
|
||||
></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item v-else>
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
<template slot="title"
|
||||
>{{ i18n "pages.inbounds.leaveBlankToNeverExpire" }}</template
|
||||
>
|
||||
{{ i18n "pages.inbounds.expireDate" }}
|
||||
<a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-date-picker
|
||||
v-if="datepicker == 'gregorian'"
|
||||
:show-time="{ format: 'HH:mm:ss' }"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
v-model="client._expiryTime"
|
||||
></a-date-picker>
|
||||
<a-persian-datepicker
|
||||
v-else
|
||||
placeholder='{{ i18n "pages.settings.datepickerPlaceholder" }}'
|
||||
value="client._expiryTime"
|
||||
v-model="client._expiryTime"
|
||||
></a-persian-datepicker>
|
||||
<a-tag color="red" v-if="isEdit && isExpiry">Expired</a-tag>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="client.expiryTime != 0">
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
<template slot="title">{{ i18n "pages.client.renewDesc" }}</template>
|
||||
{{ i18n "pages.client.renew" }}
|
||||
<a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input-number v-model.number="client.reset" :min="0"></a-input-number>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
+18
-20
@@ -1,7 +1,6 @@
|
||||
{{define "form/inbound"}}
|
||||
<!-- base -->
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }">
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-form-item label='{{ i18n "enable" }}'>
|
||||
<a-switch v-model="dbInbound.enable"></a-switch>
|
||||
</a-form-item>
|
||||
@@ -10,8 +9,7 @@
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label='{{ i18n "protocol" }}'>
|
||||
<a-select v-model="inbound.protocol" :disabled="isEdit"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select v-model="inbound.protocol" :disabled="isEdit" :dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option v-for="p in Protocols" :key="p" :value="p">[[ p
|
||||
]]</a-select-option>
|
||||
</a-select>
|
||||
@@ -31,8 +29,7 @@
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label='{{ i18n "pages.inbounds.port" }}'>
|
||||
<a-input-number v-model.number="inbound.port" :min="1"
|
||||
:max="65535"></a-input-number>
|
||||
<a-input-number v-model.number="inbound.port" :min="1" :max="65535"></a-input-number>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
@@ -45,8 +42,7 @@
|
||||
<a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input-number v-model.number="dbInbound.totalGB"
|
||||
:min="0"></a-input-number>
|
||||
<a-input-number v-model.number="dbInbound.totalGB" :min="0"></a-input-number>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
@@ -55,10 +51,8 @@
|
||||
<template slot="title">
|
||||
<span>{{ i18n "pages.inbounds.periodicTrafficResetDesc"
|
||||
}}</span>
|
||||
<br
|
||||
v-if="dbInbound.lastTrafficResetTime && dbInbound.lastTrafficResetTime > 0">
|
||||
<span
|
||||
v-if="dbInbound.lastTrafficResetTime && dbInbound.lastTrafficResetTime > 0">
|
||||
<br v-if="dbInbound.lastTrafficResetTime && dbInbound.lastTrafficResetTime > 0">
|
||||
<span v-if="dbInbound.lastTrafficResetTime && dbInbound.lastTrafficResetTime > 0">
|
||||
<strong>{{ i18n "pages.inbounds.lastReset" }}:</strong>
|
||||
<span>[[
|
||||
IntlUtil.formatDate(dbInbound.lastTrafficResetTime)
|
||||
@@ -69,10 +63,12 @@
|
||||
<a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-select v-model="dbInbound.trafficReset"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select v-model="dbInbound.trafficReset" :dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option value="never">{{ i18n
|
||||
"pages.inbounds.periodicTrafficReset.never" }}</a-select-option>
|
||||
<a-select-option value="hourly">{{ i18n
|
||||
"pages.inbounds.periodicTrafficReset.hourly"
|
||||
}}</a-select-option>
|
||||
<a-select-option value="daily">{{ i18n
|
||||
"pages.inbounds.periodicTrafficReset.daily" }}</a-select-option>
|
||||
<a-select-option value="weekly">{{ i18n
|
||||
@@ -95,13 +91,10 @@
|
||||
<a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-date-picker :style="{ width: '100%' }"
|
||||
v-if="datepicker == 'gregorian'" :show-time="{ format: 'HH:mm:ss' }"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
<a-date-picker :style="{ width: '100%' }" v-if="datepicker == 'gregorian'" :show-time="{ format: 'HH:mm:ss' }"
|
||||
format="YYYY-MM-DD HH:mm:ss" :dropdown-class-name="themeSwitcher.currentTheme"
|
||||
v-model="dbInbound._expiryTime"></a-date-picker>
|
||||
<a-persian-datepicker v-else
|
||||
placeholder='{{ i18n "pages.settings.datepickerPlaceholder" }}'
|
||||
<a-persian-datepicker v-else placeholder='{{ i18n "pages.settings.datepickerPlaceholder" }}'
|
||||
value="dbInbound._expiryTime" v-model="dbInbound._expiryTime">
|
||||
</a-persian-datepicker>
|
||||
</a-form-item>
|
||||
@@ -152,6 +145,11 @@
|
||||
{{template "form/tun"}}
|
||||
</template>
|
||||
|
||||
<!-- hysteria -->
|
||||
<template v-if="inbound.protocol === Protocols.HYSTERIA">
|
||||
{{template "form/hysteria"}}
|
||||
</template>
|
||||
|
||||
<!-- stream settings -->
|
||||
<template v-if="inbound.canEnableStream()">
|
||||
{{template "form/streamSettings"}}
|
||||
|
||||
+813
-347
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
{{define "form/hysteria"}}
|
||||
<a-collapse activeKey="0"
|
||||
v-for="(client, index) in inbound.settings.hysterias.slice(0,1)"
|
||||
v-if="!isEdit">
|
||||
<a-collapse-panel header='{{ i18n "pages.inbounds.client" }}'>
|
||||
{{template "form/client"}}
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
<a-collapse v-else>
|
||||
<a-collapse-panel :header="'{{ i18n "pages.client.clientCount"}} : ' +
|
||||
inbound.settings.hysterias.length">
|
||||
<table width="100%">
|
||||
<tr class="client-table-header">
|
||||
<th>{{ i18n "pages.inbounds.email" }}</th>
|
||||
<th>Auth</th>
|
||||
</tr>
|
||||
<tr v-for="(client, index) in inbound.settings.hysterias"
|
||||
:class="index % 2 == 1 ? 'client-table-odd-row' : ''">
|
||||
<td>[[ client.email ]]</td>
|
||||
<td>[[ client.auth ]]</td>
|
||||
</tr>
|
||||
</table>
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }">
|
||||
<a-form-item :label="'{{ i18n "pages.inbounds.stream.tcp.version" }}'">
|
||||
<a-input-number v-model.number="inbound.settings.version" :min="2"
|
||||
:max="2" disabled></a-input-number>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
{{end}}
|
||||
@@ -1,50 +1,76 @@
|
||||
{{define "form/shadowsocks"}}
|
||||
<template v-if="inbound.isSSMultiUser">
|
||||
<a-collapse activeKey="0" v-for="(client, index) in inbound.settings.shadowsockses.slice(0,1)" v-if="!isEdit">
|
||||
<a-collapse-panel header='{{ i18n "pages.inbounds.client" }}'>
|
||||
{{template "form/client"}}
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
<a-collapse v-else>
|
||||
<a-collapse-panel :header="'{{ i18n "pages.client.clientCount"}} : ' + inbound.settings.shadowsockses.length">
|
||||
<table width="100%">
|
||||
<tr class="client-table-header">
|
||||
<th>{{ i18n "pages.inbounds.email" }}</th>
|
||||
<th>Password</th>
|
||||
</tr>
|
||||
<tr v-for="(client, index) in inbound.settings.shadowsockses" :class="index % 2 == 1 ? 'client-table-odd-row' : ''">
|
||||
<td>[[ client.email ]]</td>
|
||||
<td>[[ client.password ]]</td>
|
||||
</tr>
|
||||
</table>
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
<a-collapse
|
||||
activeKey="0"
|
||||
v-for="(client, index) in inbound.settings.shadowsockses.slice(0,1)"
|
||||
v-if="!isEdit"
|
||||
>
|
||||
<a-collapse-panel header='{{ i18n "pages.inbounds.client" }}'>
|
||||
{{template "form/client"}}
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
<a-collapse v-else>
|
||||
<a-collapse-panel :header="'{{ i18n "pages.client.clientCount"}} : ' + inbound.settings.shadowsockses.length">
|
||||
<table width="100%">
|
||||
<tr class="client-table-header">
|
||||
<th>{{ i18n "pages.inbounds.email" }}</th>
|
||||
<th>Password</th>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="(client, index) in inbound.settings.shadowsockses"
|
||||
:class="index % 2 == 1 ? ' client-table-odd-row' : ''"
|
||||
>
|
||||
<td>[[ client.email ]]</td>
|
||||
<td>[[ client.password ]]</td>
|
||||
</tr>
|
||||
</table>
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</template>
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-form-item label='{{ i18n "encryption" }}'>
|
||||
<a-select v-model="inbound.settings.method" @change="SSMethodChange" :dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option v-for="(method,method_name) in SSMethods" :value="method">[[ method_name ]]</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="inbound.isSS2022">
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<span>{{ i18n "reset" }}</span>
|
||||
</template> Password <a-icon @click="inbound.settings.password = RandomUtil.randomShadowsocksPassword(inbound.settings.method)" type="sync"></a-icon>
|
||||
</a-tooltip>
|
||||
<a-form
|
||||
:colon=" false"
|
||||
:label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }"
|
||||
>
|
||||
<a-form-item label='{{ i18n "encryption" }}'>
|
||||
<a-select
|
||||
v-model="inbound.settings.method"
|
||||
@change="SSMethodChange"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
>
|
||||
<a-select-option v-for="(method,method_name) in SSMethods" :value="method"
|
||||
>[[ method_name ]]</a-select-option
|
||||
>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="inbound.isSS2022">
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<span>{{ i18n "reset" }}</span>
|
||||
</template>
|
||||
<a-input v-model.trim="inbound.settings.password"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.inbounds.network" }}'>
|
||||
<a-select v-model="inbound.settings.network" :style="{ width: '100px' }" :dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option value="tcp,udp">TCP,UDP</a-select-option>
|
||||
<a-select-option value="tcp">TCP</a-select-option>
|
||||
<a-select-option value="udp">UDP</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label='ivCheck'>
|
||||
<a-switch v-model="inbound.settings.ivCheck"></a-switch>
|
||||
</a-form-item>
|
||||
Password
|
||||
<a-icon
|
||||
@click="inbound.settings.password = RandomUtil.randomShadowsocksPassword(inbound.settings.method)"
|
||||
type="sync"
|
||||
></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input v-model.trim="inbound.settings.password"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.inbounds.network" }}'>
|
||||
<a-select
|
||||
v-model="inbound.settings.network"
|
||||
:style="{ width: '100px' }"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
>
|
||||
<a-select-option value="tcp,udp">TCP,UDP</a-select-option>
|
||||
<a-select-option value="tcp">TCP</a-select-option>
|
||||
<a-select-option value="udp">UDP</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="ivCheck">
|
||||
<a-switch v-model="inbound.settings.ivCheck"></a-switch>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
{{end}}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{{define "form/mixed"}}
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-form
|
||||
:colon="false"
|
||||
:label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }"
|
||||
>
|
||||
<a-form-item label='{{ i18n "pages.inbounds.enable" }} UDP'>
|
||||
<a-switch v-model="inbound.settings.udp"></a-switch>
|
||||
</a-form-item>
|
||||
@@ -7,7 +11,10 @@
|
||||
<a-input v-model.trim="inbound.settings.ip"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "password" }}'>
|
||||
<a-switch :checked="inbound.settings.auth === 'password'" @change="checked => inbound.settings.auth = checked ? 'password' : 'noauth'"></a-switch>
|
||||
<a-switch
|
||||
:checked="inbound.settings.auth === 'password'"
|
||||
@change="checked => inbound.settings.auth = checked ? 'password' : 'noauth'"
|
||||
></a-switch>
|
||||
</a-form-item>
|
||||
<template v-if="inbound.settings.auth === 'password'">
|
||||
<table :style="{ width: '100%', textAlign: 'center', margin: '1rem 0' }">
|
||||
@@ -15,17 +22,39 @@
|
||||
<td width="45%">{{ i18n "username" }}</td>
|
||||
<td width="45%">{{ i18n "password" }}</td>
|
||||
<td>
|
||||
<a-button icon="plus" size="small" @click="inbound.settings.addAccount(new Inbound.MixedSettings.SocksAccount())"></a-button>
|
||||
<a-button
|
||||
icon="plus"
|
||||
size="small"
|
||||
@click="inbound.settings.addAccount(new Inbound.MixedSettings.SocksAccount())"
|
||||
></a-button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<a-input-group compact v-for="(account, index) in inbound.settings.accounts" :style="{ marginBottom: '10px' }">
|
||||
<a-input :style="{ width: '50%' }" v-model.trim="account.user" placeholder='{{ i18n "username" }}'>
|
||||
<template slot="addonBefore" :style="{ margin: '0' }">[[ index+1 ]]</template>
|
||||
<a-input-group
|
||||
compact
|
||||
v-for="(account, index) in inbound.settings.accounts"
|
||||
:style="{ marginBottom: '10px' }"
|
||||
>
|
||||
<a-input
|
||||
:style="{ width: '50%' }"
|
||||
v-model.trim="account.user"
|
||||
placeholder='{{ i18n "username" }}'
|
||||
>
|
||||
<template slot="addonBefore" :style="{ margin: '0' }"
|
||||
>[[ index+1 ]]</template
|
||||
>
|
||||
</a-input>
|
||||
<a-input :style="{ width: '50%' }" v-model.trim="account.pass" placeholder='{{ i18n "password" }}'>
|
||||
<a-input
|
||||
:style="{ width: '50%' }"
|
||||
v-model.trim="account.pass"
|
||||
placeholder='{{ i18n "password" }}'
|
||||
>
|
||||
<template slot="addonAfter">
|
||||
<a-button icon="minus" size="small" @click="inbound.settings.delAccount(index)"></a-button>
|
||||
<a-button
|
||||
icon="minus"
|
||||
size="small"
|
||||
@click="inbound.settings.delAccount(index)"
|
||||
></a-button>
|
||||
</template>
|
||||
</a-input>
|
||||
</a-input-group>
|
||||
|
||||
@@ -11,40 +11,43 @@
|
||||
<th>{{ i18n "pages.inbounds.email" }}</th>
|
||||
<th>Password</th>
|
||||
</tr>
|
||||
<tr v-for="(client, index) in inbound.settings.trojans" :class="index % 2 == 1 ? 'client-table-odd-row' : ''">
|
||||
<tr v-for="(client, index) in inbound.settings.trojans" :class="index % 2 == 1 ? ' client-table-odd-row' : ''">
|
||||
<td>[[ client.email ]]</td>
|
||||
<td>[[ client.password ]]</td>
|
||||
</tr>
|
||||
</table>
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
<template v-if="inbound.isTcp">
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-form-item label="Fallbacks">
|
||||
<a-button icon="plus" type="primary" size="small" @click="inbound.settings.addFallback()"></a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<template v-if=" inbound.isTcp">
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-form-item label="Fallbacks">
|
||||
<a-button icon="plus" type="primary" size="small" @click="inbound.settings.addFallback()"></a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<!-- trojan fallbacks -->
|
||||
<a-form v-for="(fallback, index) in inbound.settings.fallbacks" :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-divider :style="{ margin: '0' }"> Fallback [[ index + 1 ]] <a-icon type="delete" @click="() => inbound.settings.delFallback(index)" :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"></a-icon>
|
||||
</a-divider>
|
||||
<a-form-item label='SNI'>
|
||||
<a-input v-model="fallback.name"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='ALPN'>
|
||||
<a-input v-model="fallback.alpn"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='Path'>
|
||||
<a-input v-model="fallback.path"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='Dest'>
|
||||
<a-input v-model="fallback.dest"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='xVer'>
|
||||
<a-input-number v-model.number="fallback.xver" :min="0" :max="2"></a-input-number>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<a-divider style="margin:5px 0;"></a-divider>
|
||||
</template>
|
||||
{{end}}
|
||||
<!-- trojan fallbacks -->
|
||||
<a-form v-for="(fallback, index) in inbound.settings.fallbacks" :colon="false" :label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }">
|
||||
<a-divider :style="{ margin: '0' }"> Fallback [[ index + 1 ]] <a-icon type="delete"
|
||||
@click="() => inbound.settings.delFallback(index)"
|
||||
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"></a-icon>
|
||||
</a-divider>
|
||||
<a-form-item label='SNI'>
|
||||
<a-input v-model="fallback.name"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='ALPN'>
|
||||
<a-input v-model="fallback.alpn"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='Path'>
|
||||
<a-input v-model="fallback.path"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='Dest'>
|
||||
<a-input v-model="fallback.dest"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='xVer'>
|
||||
<a-input-number v-model.number="fallback.xver" :min="0" :max="2"></a-input-number>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<a-divider style="margin:5px 0;"></a-divider>
|
||||
</template>
|
||||
{{end}}
|
||||
@@ -1,44 +1,94 @@
|
||||
{{define "form/tun"}}
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }">
|
||||
<a-form-item>
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<span>{{ i18n "pages.xray.tun.nameDesc" }}</span>
|
||||
</template>
|
||||
Interface Name
|
||||
<a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
<a-form
|
||||
:colon="false"
|
||||
:label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }"
|
||||
>
|
||||
<a-form-item>
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<span>{{ i18n "pages.xray.tun.nameDesc" }}</span>
|
||||
</template>
|
||||
<a-input v-model.trim="inbound.settings.name"
|
||||
placeholder="xray0"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<span>{{ i18n "pages.xray.tun.mtuDesc" }}</span>
|
||||
</template>
|
||||
MTU
|
||||
<a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
Interface Name
|
||||
<a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input v-model.trim="inbound.settings.name" placeholder="xray0"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<span>{{ i18n "pages.xray.tun.mtuDesc" }}</span>
|
||||
</template>
|
||||
<a-input-number v-model.number="inbound.settings.mtu" :min="1"
|
||||
:max="9000" placeholder="1500"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<span>{{ i18n "pages.xray.tun.userLevelDesc" }}</span>
|
||||
</template>
|
||||
{{ i18n "pages.xray.tun.userLevel" }}
|
||||
<a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
MTU IPv4
|
||||
<a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input-number
|
||||
v-model.number="inbound.settings.mtu[0]"
|
||||
:min="1"
|
||||
:max="9000"
|
||||
placeholder="1500"
|
||||
></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="MTU IPv6">
|
||||
<a-input-number
|
||||
v-model.number="inbound.settings.mtu[1]"
|
||||
:min="1"
|
||||
:max="9000"
|
||||
placeholder="1280"
|
||||
></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="Gateway">
|
||||
<a-select
|
||||
mode="tags"
|
||||
v-model="inbound.settings.gateway"
|
||||
:style="{ width: '100%' }"
|
||||
:token-separators="[',']"
|
||||
placeholder="IPv4/IPv6 gateway"
|
||||
></a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="DNS">
|
||||
<a-select
|
||||
mode="tags"
|
||||
v-model="inbound.settings.dns"
|
||||
:style="{ width: '100%' }"
|
||||
:token-separators="[',']"
|
||||
placeholder="DNS servers"
|
||||
></a-select>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<span>{{ i18n "pages.xray.tun.userLevelDesc" }}</span>
|
||||
</template>
|
||||
<a-input-number v-model.number="inbound.settings.userLevel" :min="0"
|
||||
placeholder="0"></a-input-number>
|
||||
</a-form-item>
|
||||
{{ i18n "pages.xray.tun.userLevel" }}
|
||||
<a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input-number
|
||||
v-model.number="inbound.settings.userLevel"
|
||||
:min="0"
|
||||
placeholder="0"
|
||||
></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="Auto Routing Table">
|
||||
<a-select
|
||||
mode="tags"
|
||||
v-model="inbound.settings.autoSystemRoutingTable"
|
||||
:style="{ width: '100%' }"
|
||||
:token-separators="[',']"
|
||||
placeholder="e.g. vpn, proxy"
|
||||
></a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Auto Outbounds">
|
||||
<a-input
|
||||
v-model.trim="inbound.settings.autoOutboundsInterface"
|
||||
placeholder="auto"
|
||||
></a-input>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
{{end}}
|
||||
|
||||
@@ -120,4 +120,4 @@
|
||||
</a-form>
|
||||
<a-divider :style="{ margin: '5px 0' }"></a-divider>
|
||||
</template>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -1,5 +1,5 @@
|
||||
{{define "form/vmess"}}
|
||||
<a-collapse activeKey="0" v-for="(client, index) in inbound.settings.vmesses.slice(0,1)" v-if="!isEdit">
|
||||
<a-collapse activeKey="0" v-for="(client, index) in inbound.settings.vmesses.slice(0,1)" v-if="!isEdit">
|
||||
<a-collapse-panel header='{{ i18n "pages.inbounds.client" }}'>
|
||||
{{template "form/client"}}
|
||||
</a-collapse-panel>
|
||||
@@ -12,7 +12,8 @@
|
||||
<th>ID</th>
|
||||
<th>{{ i18n "security" }}</th>
|
||||
</tr>
|
||||
<tr v-for="(client, index) in inbound.settings.vmesses" :class="index % 2 == 1 ? 'client-table-odd-row' : ''">
|
||||
<tr v-for="(client, index) in inbound.settings.vmesses"
|
||||
:class="index % 2 == 1 ? ' client-table-odd-row' : ''">
|
||||
<td>[[ client.email ]]</td>
|
||||
<td>[[ client.id ]]</td>
|
||||
<td>[[ client.security ]]</td>
|
||||
@@ -20,4 +21,4 @@
|
||||
</table>
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -7,7 +7,8 @@
|
||||
<span>{{ i18n "reset" }}</span>
|
||||
</template>
|
||||
{{ i18n "pages.xray.wireguard.secretKey" }}
|
||||
<a-icon type="sync" @click="[inbound.settings.pubKey, inbound.settings.secretKey] = Object.values(Wireguard.generateKeypair())"></a-icon>
|
||||
<a-icon type="sync"
|
||||
@click="[inbound.settings.pubKey, inbound.settings.secretKey] = Object.values(Wireguard.generateKeypair())"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input v-model.trim="inbound.settings.secretKey"></a-input>
|
||||
@@ -24,8 +25,11 @@
|
||||
<a-form-item label="Peers">
|
||||
<a-button icon="plus" type="primary" size="small" @click="inbound.settings.addPeer()"></a-button>
|
||||
</a-form-item>
|
||||
<a-form v-for="(peer, index) in inbound.settings.peers" :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-divider :style="{ margin: '0' }"> Peer [[ index + 1 ]] <a-icon v-if="inbound.settings.peers.length>1" type="delete" @click="() => inbound.settings.delPeer(index)" :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"></a-icon>
|
||||
<a-form v-for="(peer, index) in inbound.settings.peers" :colon="false" :label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }">
|
||||
<a-divider :style="{ margin: '0' }"> Peer [[ index + 1 ]] <a-icon v-if="inbound.settings.peers.length>1"
|
||||
type="delete" @click="() => inbound.settings.delPeer(index)"
|
||||
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"></a-icon>
|
||||
</a-divider>
|
||||
<a-form-item>
|
||||
<template slot="label">
|
||||
@@ -34,7 +38,8 @@
|
||||
<span>{{ i18n "reset" }}</span>
|
||||
</template>
|
||||
{{ i18n "pages.xray.wireguard.secretKey" }}
|
||||
<a-icon @click="[peer.publicKey, peer.privateKey] = Object.values(Wireguard.generateKeypair())" type="sync"></a-icon>
|
||||
<a-icon @click="[peer.publicKey, peer.privateKey] = Object.values(Wireguard.generateKeypair())"
|
||||
type="sync"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input v-model.trim="peer.privateKey"></a-input>
|
||||
@@ -64,7 +69,8 @@
|
||||
</template>
|
||||
<template v-for="(aip, index) in peer.allowedIPs" :style="{ marginBottom: '10px' }">
|
||||
<a-input v-model.trim="peer.allowedIPs[index]">
|
||||
<a-button icon="minus" v-if="peer.allowedIPs.length>1" slot="addonAfter" size="small" @click="peer.allowedIPs.splice(index, 1)"></a-button>
|
||||
<a-button icon="minus" v-if="peer.allowedIPs.length>1" slot="addonAfter" size="small"
|
||||
@click="peer.allowedIPs.splice(index, 1)"></a-button>
|
||||
</a-input>
|
||||
</template>
|
||||
</a-form-item>
|
||||
|
||||
@@ -17,8 +17,7 @@
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<span>{{ i18n "reset" }}</span>
|
||||
</template> Target <a-icon @click="randomizeRealityTarget()"
|
||||
type="sync"></a-icon>
|
||||
</template> Target <a-icon @click="randomizeRealityTarget()" type="sync"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input v-model.trim="inbound.stream.reality.target"></a-input>
|
||||
@@ -28,8 +27,7 @@
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<span>{{ i18n "reset" }}</span>
|
||||
</template> SNI <a-icon @click="randomizeRealityTarget()"
|
||||
type="sync"></a-icon>
|
||||
</template> SNI <a-icon @click="randomizeRealityTarget()" type="sync"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input v-model.trim="inbound.stream.reality.serverNames"></a-input>
|
||||
|
||||
+34
-10
@@ -1,29 +1,53 @@
|
||||
{{define "form/sniffing"}}
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-form
|
||||
:colon="false"
|
||||
:label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }"
|
||||
>
|
||||
<a-form-item>
|
||||
<span slot="label">
|
||||
{{ i18n "enabled" }}
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<span>{{ i18n "pages.inbounds.noRecommendKeepDefault" }}</span>
|
||||
</template>
|
||||
<a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<span>{{ i18n "pages.inbounds.noRecommendKeepDefault" }}</span>
|
||||
</template>
|
||||
<a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
</span>
|
||||
<a-switch v-model="inbound.sniffing.enabled"></a-switch>
|
||||
</a-form-item>
|
||||
<template v-if="inbound.sniffing.enabled">
|
||||
<a-form-item :wrapper-col="{span:24}">
|
||||
<a-checkbox-group v-model="inbound.sniffing.destOverride">
|
||||
<a-checkbox v-for="key,value in SNIFFING_OPTION" :value="key">[[ value ]]</a-checkbox>
|
||||
<a-checkbox v-for="key,value in SNIFFING_OPTION" :value="key"
|
||||
>[[ value ]]</a-checkbox
|
||||
>
|
||||
</a-checkbox-group>
|
||||
</a-form-item>
|
||||
<a-form-item label='Metadata Only'>
|
||||
<a-form-item label="Metadata Only">
|
||||
<a-switch v-model="inbound.sniffing.metadataOnly"></a-switch>
|
||||
</a-form-item>
|
||||
<a-form-item label='Route Only'>
|
||||
<a-form-item label="Route Only">
|
||||
<a-switch v-model="inbound.sniffing.routeOnly"></a-switch>
|
||||
</a-form-item>
|
||||
<a-form-item label="IPs Excluded">
|
||||
<a-select
|
||||
mode="tags"
|
||||
v-model="inbound.sniffing.ipsExcluded"
|
||||
:style="{ width: '100%' }"
|
||||
:token-separators="[',']"
|
||||
placeholder="IP/CIDR/geoip:*/ext:*"
|
||||
></a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Domains Excluded">
|
||||
<a-select
|
||||
mode="tags"
|
||||
v-model="inbound.sniffing.domainsExcluded"
|
||||
:style="{ width: '100%' }"
|
||||
:token-separators="[',']"
|
||||
placeholder="domain:*/ext:*"
|
||||
></a-select>
|
||||
</a-form-item>
|
||||
</template>
|
||||
</a-form>
|
||||
{{end}}
|
||||
|
||||
@@ -1,31 +1,67 @@
|
||||
{{define "form/externalProxy"}}
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-form
|
||||
:colon="false"
|
||||
:label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }"
|
||||
>
|
||||
<a-divider :style="{ margin: '5px 0 0' }"></a-divider>
|
||||
<a-form-item label="External Proxy">
|
||||
<a-switch v-model="externalProxy"></a-switch>
|
||||
<a-button icon="plus" v-if="externalProxy" type="primary" :style="{ marginLeft: '10px' }" size="small"
|
||||
@click="inbound.stream.externalProxy.push({forceTls: 'same', dest: '', port: 443, remark: ''})"></a-button>
|
||||
<a-button
|
||||
icon="plus"
|
||||
v-if="externalProxy"
|
||||
type="primary"
|
||||
:style="{ marginLeft: '10px' }"
|
||||
size="small"
|
||||
@click="inbound.stream.externalProxy.push({forceTls: 'same', dest: '', port: 443, remark: ''})"
|
||||
></a-button>
|
||||
</a-form-item>
|
||||
<a-input-group :style="{ margin: '8px 0' }" compact v-for="(row, index) in inbound.stream.externalProxy">
|
||||
<a-input-group
|
||||
:style="{ margin: '8px 0' }"
|
||||
compact
|
||||
v-for="(row, index) in inbound.stream.externalProxy"
|
||||
>
|
||||
<template>
|
||||
<a-tooltip title="Force TLS">
|
||||
<a-select v-model="row.forceTls" :style="{ width: '20%', margin: '0px' }"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option value="same">{{ i18n "pages.inbounds.same" }}</a-select-option>
|
||||
<a-select
|
||||
v-model="row.forceTls"
|
||||
:style="{ width: '20%', margin: '0px' }"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
>
|
||||
<a-select-option value="same"
|
||||
>{{ i18n "pages.inbounds.same" }}</a-select-option
|
||||
>
|
||||
<a-select-option value="none">{{ i18n "none" }}</a-select-option>
|
||||
<a-select-option value="tls">TLS</a-select-option>
|
||||
</a-select>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input :style="{ width: '30%' }" v-model.trim="row.dest" placeholder='{{ i18n "host" }}'></a-input>
|
||||
<a-input
|
||||
:style="{ width: '30%' }"
|
||||
v-model.trim="row.dest"
|
||||
placeholder='{{ i18n "host" }}'
|
||||
></a-input>
|
||||
<a-tooltip title='{{ i18n "pages.inbounds.port" }}'>
|
||||
<a-input-number :style="{ width: '15%' }" v-model.number="row.port" min="1" max="65535"></a-input-number>
|
||||
<a-input-number
|
||||
:style="{ width: '15%' }"
|
||||
v-model.number="row.port"
|
||||
min="1"
|
||||
max="65535"
|
||||
></a-input-number>
|
||||
</a-tooltip>
|
||||
<a-input :style="{ width: '30%', top: '0' }" v-model.trim="row.remark" placeholder='{{ i18n "remark" }}'>
|
||||
<a-input
|
||||
:style="{ width: '30%', top: '0' }"
|
||||
v-model.trim="row.remark"
|
||||
placeholder='{{ i18n "remark" }}'
|
||||
>
|
||||
<template slot="addonAfter">
|
||||
<a-button icon="minus" size="small" @click="inbound.stream.externalProxy.splice(index, 1)"></a-button>
|
||||
<a-button
|
||||
icon="minus"
|
||||
size="small"
|
||||
@click="inbound.stream.externalProxy.splice(index, 1)"
|
||||
></a-button>
|
||||
</template>
|
||||
</a-input>
|
||||
</a-input-group>
|
||||
</a-form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
@@ -1,84 +1,260 @@
|
||||
{{define "form/streamFinalMask"}}
|
||||
<a-divider :style="{ margin: '5px 0 0' }"></a-divider>
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }">
|
||||
<a-form-item label="UDP Masks">
|
||||
<a-button icon="plus" type="primary" size="small"
|
||||
@click="inbound.stream.addUdpMask(inbound.stream.network === 'kcp' ? 'mkcp-aes128gcm' : 'xdns')"></a-button>
|
||||
</a-form-item>
|
||||
<template
|
||||
v-if="inbound.stream.finalmask.udp && inbound.stream.finalmask.udp.length > 0">
|
||||
<a-form v-for="(mask, index) in inbound.stream.finalmask.udp"
|
||||
:key="index" :colon="false"
|
||||
:label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-divider :style="{ margin: '0' }"> UDP Mask [[ index + 1 ]]
|
||||
<a-icon type="delete"
|
||||
@click="() => inbound.stream.delUdpMask(index)"
|
||||
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"></a-icon>
|
||||
</a-divider>
|
||||
<a-form-item label='Type'>
|
||||
<a-select v-model="mask.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">
|
||||
<!-- mKCP-specific masks -->
|
||||
<a-select-option v-if="inbound.stream.network === 'kcp'"
|
||||
value="mkcp-aes128gcm">
|
||||
mKCP AES-128-GCM</a-select-option>
|
||||
<a-select-option v-if="inbound.stream.network === 'kcp'"
|
||||
value="header-dns">
|
||||
Header DNS</a-select-option>
|
||||
<a-select-option v-if="inbound.stream.network === 'kcp'"
|
||||
value="header-dtls">
|
||||
Header DTLS 1.2</a-select-option>
|
||||
<a-select-option v-if="inbound.stream.network === 'kcp'"
|
||||
value="header-srtp">
|
||||
Header SRTP</a-select-option>
|
||||
<a-select-option v-if="inbound.stream.network === 'kcp'"
|
||||
value="header-utp">
|
||||
Header uTP</a-select-option>
|
||||
<a-select-option v-if="inbound.stream.network === 'kcp'"
|
||||
value="header-wechat">
|
||||
Header WeChat Video</a-select-option>
|
||||
<a-select-option v-if="inbound.stream.network === 'kcp'"
|
||||
value="header-wireguard">
|
||||
Header WireGuard</a-select-option>
|
||||
<a-select-option v-if="inbound.stream.network === 'kcp'"
|
||||
value="mkcp-original">
|
||||
mKCP Original</a-select-option>
|
||||
<a-select-option v-if="inbound.stream.network === 'kcp'"
|
||||
value="xicmp">
|
||||
xICMP (Experimental)</a-select-option>
|
||||
<!-- xDNS for TCP/WS/HTTPUpgrade/XHTTP/KCP -->
|
||||
<a-select-option
|
||||
v-if="['tcp', 'ws', 'httpupgrade', 'xhttp', 'kcp'].includes(inbound.stream.network)"
|
||||
value="xdns">
|
||||
xDNS (Experimental)</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<!-- Settings for password-based masks -->
|
||||
<a-form-item label='Password'
|
||||
v-if="['mkcp-aes128gcm'].includes(mask.type)">
|
||||
<a-input v-model.trim="mask.settings.password"
|
||||
placeholder="Obfuscation password"></a-input>
|
||||
</a-form-item>
|
||||
<!-- Settings for domain-based masks -->
|
||||
<a-form-item label='Domain'
|
||||
v-if="['header-dns', 'xdns'].includes(mask.type)">
|
||||
<a-input v-model.trim="mask.settings.domain"
|
||||
placeholder="e.g., www.example.com"></a-input>
|
||||
</a-form-item>
|
||||
<!-- Settings for xICMP -->
|
||||
<a-form-item label='IP'
|
||||
v-if="mask.type === 'xicmp'">
|
||||
<a-input v-model.trim="mask.settings.ip"
|
||||
placeholder="e.g., 1.1.1.1"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='ID'
|
||||
v-if="mask.type === 'xicmp'">
|
||||
<a-input-number v-model.number="mask.settings.id"
|
||||
:min="0" :max="65535"></a-input-number>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</template>
|
||||
<a-form
|
||||
:colon="false"
|
||||
:label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }"
|
||||
v-if="inbound.protocol == Protocols.HYSTERIA || inbound.stream.network == 'kcp'"
|
||||
>
|
||||
<a-divider :style="{ margin: '5px 0 0' }"></a-divider>
|
||||
<a-form-item label="UDP Masks">
|
||||
<a-button
|
||||
icon="plus"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="inbound.stream.addUdpMask(inbound.protocol === Protocols.HYSTERIA ? 'salamander' : 'mkcp-aes128gcm')"
|
||||
></a-button>
|
||||
</a-form-item>
|
||||
<template
|
||||
v-if="inbound.stream.finalmask.udp && inbound.stream.finalmask.udp.length > 0"
|
||||
>
|
||||
<a-form
|
||||
v-for="(mask, index) in inbound.stream.finalmask.udp"
|
||||
:key="index"
|
||||
:colon="false"
|
||||
:label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }"
|
||||
>
|
||||
<a-divider :style="{ margin: '0' }">
|
||||
UDP Mask [[ index + 1 ]]
|
||||
<a-icon
|
||||
type="delete"
|
||||
@click="() => inbound.stream.delUdpMask(index)"
|
||||
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"
|
||||
></a-icon>
|
||||
</a-divider>
|
||||
<a-form-item label='{{ i18n "pages.xray.outbound.type" }}'>
|
||||
<a-select
|
||||
v-model="mask.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"
|
||||
>
|
||||
<template v-if="inbound.protocol === Protocols.HYSTERIA">
|
||||
<a-select-option value="salamander"
|
||||
>Salamander (Hysteria2)</a-select-option
|
||||
>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-select-option value="mkcp-aes128gcm"
|
||||
>mKCP AES-128-GCM</a-select-option
|
||||
>
|
||||
<a-select-option value="header-dns">Header DNS</a-select-option>
|
||||
<a-select-option value="header-dtls"
|
||||
>Header DTLS 1.2</a-select-option
|
||||
>
|
||||
<a-select-option value="header-srtp">Header SRTP</a-select-option>
|
||||
<a-select-option value="header-utp">Header uTP</a-select-option>
|
||||
<a-select-option value="header-wechat"
|
||||
>Header WeChat Video</a-select-option
|
||||
>
|
||||
<a-select-option value="header-wireguard"
|
||||
>Header WireGuard</a-select-option
|
||||
>
|
||||
<a-select-option value="mkcp-original"
|
||||
>mKCP Original</a-select-option
|
||||
>
|
||||
<a-select-option value="xdns">xDNS</a-select-option>
|
||||
<a-select-option value="xicmp">xICMP</a-select-option>
|
||||
<a-select-option value="header-custom"
|
||||
>Header Custom</a-select-option
|
||||
>
|
||||
<a-select-option value="noise">Noise</a-select-option>
|
||||
<a-select-option value="sudoku">Sudoku</a-select-option>
|
||||
</template>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
label="Password"
|
||||
v-if="['mkcp-aes128gcm', 'salamander', 'sudoku'].includes(mask.type)"
|
||||
>
|
||||
<a-input
|
||||
v-model.trim="mask.settings.password"
|
||||
placeholder="Obfuscation password"
|
||||
></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Domain" v-if="mask.type === 'header-dns'">
|
||||
<a-input
|
||||
v-model.trim="mask.settings.domain"
|
||||
placeholder="e.g., www.example.com"
|
||||
></a-input>
|
||||
</a-form-item>
|
||||
<template v-if="mask.type === 'xdns'">
|
||||
<a-form-item label="Domains">
|
||||
<a-select
|
||||
mode="tags"
|
||||
v-model="mask.settings.domains"
|
||||
:style="{ width: '100%' }"
|
||||
:token-separators="[',']"
|
||||
placeholder="e.g., www.example.com"
|
||||
></a-select>
|
||||
</a-form-item>
|
||||
</template>
|
||||
<template v-if="mask.type === 'header-custom'">
|
||||
<a-form-item label="Client">
|
||||
<a-icon
|
||||
type="plus"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="mask.settings.client.push({rand: 0, randRange: '0-255', type: 'array', packet: []})"
|
||||
/>
|
||||
</a-form-item>
|
||||
<template v-for="(c, index) in mask.settings.client" :key="index">
|
||||
<a-divider :style="{ margin: '0' }">
|
||||
Client [[ index + 1 ]]
|
||||
<a-icon
|
||||
type="delete"
|
||||
@click="() => mask.settings.client.splice(index, 1)"
|
||||
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"
|
||||
></a-icon>
|
||||
</a-divider>
|
||||
<a-form-item label="Rand">
|
||||
<a-input-number v-model.number="c.rand" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Rand Range">
|
||||
<a-input v-model.trim="c.randRange" placeholder="0-255" />
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.xray.outbound.type" }}'>
|
||||
<a-select
|
||||
v-model="c.type"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
>
|
||||
<a-select-option value="array">Array</a-select-option>
|
||||
<a-select-option value="str">String</a-select-option>
|
||||
<a-select-option value="hex">Hex</a-select-option>
|
||||
<a-select-option value="base64">Base64</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Packet">
|
||||
<a-input v-model.trim="c.packet" placeholder="binary data" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
<a-divider :style="{ margin: '0' }"></a-divider>
|
||||
<a-form-item label="Server">
|
||||
<a-icon
|
||||
type="plus"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="mask.settings.server.push({rand: 0, randRange: '0-255', type: 'array', packet: []})"
|
||||
/>
|
||||
</a-form-item>
|
||||
<template v-for="(s, index) in mask.settings.server" :key="index">
|
||||
<a-divider :style="{ margin: '0' }">
|
||||
Server [[ index + 1 ]]
|
||||
<a-icon
|
||||
type="delete"
|
||||
@click="() => mask.settings.server.splice(index, 1)"
|
||||
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"
|
||||
></a-icon>
|
||||
</a-divider>
|
||||
<a-form-item label="Rand">
|
||||
<a-input-number v-model.number="s.rand" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Rand Range">
|
||||
<a-input v-model.trim="s.randRange" placeholder="0-255" />
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.xray.outbound.type" }}'>
|
||||
<a-select
|
||||
v-model="s.type"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
>
|
||||
<a-select-option value="array">Array</a-select-option>
|
||||
<a-select-option value="str">String</a-select-option>
|
||||
<a-select-option value="hex">Hex</a-select-option>
|
||||
<a-select-option value="base64">Base64</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Packet">
|
||||
<a-input v-model.trim="s.packet" placeholder="binary data" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
</template>
|
||||
<template v-if="mask.type === 'noise'">
|
||||
<a-form-item label="Reset">
|
||||
<a-input-number v-model.number="mask.settings.reset" :min="0" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Noise">
|
||||
<a-icon
|
||||
type="plus"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="mask.settings.noise.push({rand: '1-8192', randRange: '0-255', type: 'array', packet: '', delay: ''})"
|
||||
/>
|
||||
</a-form-item>
|
||||
<template v-for="(n, index) in mask.settings.noise" :key="index">
|
||||
<a-divider :style="{ margin: '0' }">
|
||||
Noise [[ index + 1 ]]
|
||||
<a-icon
|
||||
type="delete"
|
||||
@click="() => mask.settings.noise.splice(index, 1)"
|
||||
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"
|
||||
></a-icon>
|
||||
</a-divider>
|
||||
<a-form-item label="Rand">
|
||||
<a-input v-model.trim="n.rand" placeholder="1-8192" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Rand Range">
|
||||
<a-input v-model.trim="n.randRange" placeholder="0-255" />
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.xray.outbound.type" }}'>
|
||||
<a-select
|
||||
v-model="n.type"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
>
|
||||
<a-select-option value="array">Array</a-select-option>
|
||||
<a-select-option value="str">String</a-select-option>
|
||||
<a-select-option value="hex">Hex</a-select-option>
|
||||
<a-select-option value="base64">Base64</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Packet">
|
||||
<a-input v-model.trim="n.packet" placeholder="binary data" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Delay">
|
||||
<a-input v-model.trim="n.delay" placeholder="10-20" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
</template>
|
||||
<template v-if="mask.type === 'sudoku'">
|
||||
<a-form-item label="ASCII">
|
||||
<a-input v-model.trim="mask.settings.ascii" placeholder="ASCII" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Custom Table">
|
||||
<a-input
|
||||
v-model.trim="mask.settings.customTable"
|
||||
placeholder="Custom Table"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="Custom Tables">
|
||||
<a-input
|
||||
v-model.trim="mask.settings.customTables"
|
||||
placeholder="Custom Tables"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="Padding Min">
|
||||
<a-input-number v-model.number="mask.settings.paddingMin" :min="0" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Padding Max">
|
||||
<a-input-number v-model.number="mask.settings.paddingMax" :min="0" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
<template v-if="mask.type === 'xicmp'">
|
||||
<a-form-item label="IP">
|
||||
<a-input v-model.trim="mask.settings.ip" placeholder="0.0.0.0" />
|
||||
</a-form-item>
|
||||
<a-form-item label="ID">
|
||||
<a-input-number v-model.number="mask.settings.id" :min="0" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
</a-form>
|
||||
</template>
|
||||
</a-form>
|
||||
{{end}}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
{{define "form/streamGRPC"}}
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-form-item label="Service Name">
|
||||
<a-input v-model.trim="inbound.stream.grpc.serviceName"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Authority">
|
||||
<a-input v-model.trim="inbound.stream.grpc.authority"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Multi Mode">
|
||||
<a-switch v-model="inbound.stream.grpc.multiMode"></a-switch>
|
||||
</a-form-item>
|
||||
<a-form
|
||||
:colon="false"
|
||||
:label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }"
|
||||
>
|
||||
<a-form-item label="Service Name">
|
||||
<a-input v-model.trim="inbound.stream.grpc.serviceName"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Authority">
|
||||
<a-input v-model.trim="inbound.stream.grpc.authority"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Multi Mode">
|
||||
<a-switch v-model="inbound.stream.grpc.multiMode"></a-switch>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
{{end}}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
{{define "form/streamHTTPUpgrade"}}
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-form
|
||||
:colon="false"
|
||||
:label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }"
|
||||
>
|
||||
<a-form-item label="Proxy Protocol">
|
||||
<a-switch v-model="inbound.stream.httpupgrade.acceptProxyProtocol"></a-switch>
|
||||
<a-switch
|
||||
v-model="inbound.stream.httpupgrade.acceptProxyProtocol"
|
||||
></a-switch>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "host" }}'>
|
||||
<a-input v-model.trim="inbound.stream.httpupgrade.host"></a-input>
|
||||
@@ -10,15 +16,37 @@
|
||||
<a-input v-model.trim="inbound.stream.httpupgrade.path"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.inbounds.stream.tcp.requestHeader" }}'>
|
||||
<a-button icon="plus" size="small" @click="inbound.stream.httpupgrade.addHeader('', '')"></a-button>
|
||||
<a-button
|
||||
icon="plus"
|
||||
size="small"
|
||||
@click="inbound.stream.httpupgrade.addHeader('', '')"
|
||||
></a-button>
|
||||
</a-form-item>
|
||||
<a-form-item :wrapper-col="{span:24}">
|
||||
<a-input-group compact v-for="(header, index) in inbound.stream.httpupgrade.headers">
|
||||
<a-input :style="{ width: '50%' }" v-model.trim="header.name" placeholder='{{ i18n "pages.inbounds.stream.general.name"}}'>
|
||||
<template slot="addonBefore" :style="{ margin: '0' }">[[ index+1 ]]</template>
|
||||
<a-input-group
|
||||
compact
|
||||
v-for="(header, index) in inbound.stream.httpupgrade.headers"
|
||||
>
|
||||
<a-input
|
||||
:style="{ width: '50%' }"
|
||||
v-model.trim="header.name"
|
||||
placeholder='{{ i18n "pages.inbounds.stream.general.name"}}'
|
||||
>
|
||||
<template slot="addonBefore" :style="{ margin: '0' }"
|
||||
>[[ index+1 ]]</template
|
||||
>
|
||||
</a-input>
|
||||
<a-input :style="{ width: '50%' }" v-model.trim="header.value" placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'>
|
||||
<a-button icon="minus" slot="addonAfter" size="small" @click="inbound.stream.httpupgrade.removeHeader(index)"></a-button>
|
||||
<a-input
|
||||
:style="{ width: '50%' }"
|
||||
v-model.trim="header.value"
|
||||
placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'
|
||||
>
|
||||
<a-button
|
||||
icon="minus"
|
||||
slot="addonAfter"
|
||||
size="small"
|
||||
@click="inbound.stream.httpupgrade.removeHeader(index)"
|
||||
></a-button>
|
||||
</a-input>
|
||||
</a-input-group>
|
||||
</a-form-item>
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
{{define "form/streamHysteria"}}
|
||||
<a-form
|
||||
:colon="false"
|
||||
:label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }"
|
||||
>
|
||||
<a-form-item>
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<span>{{ i18n "reset" }}</span>
|
||||
</template>
|
||||
Auth Password
|
||||
<a-icon
|
||||
@click="inbound.stream.hysteria.auth = RandomUtil.randomSeq(10)"
|
||||
type="sync"
|
||||
></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input v-model.trim="inbound.stream.hysteria.auth"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="UDP Idle Timeout">
|
||||
<a-input-number
|
||||
v-model.number="inbound.stream.hysteria.udpIdleTimeout"
|
||||
:min="0"
|
||||
></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="Masquerade">
|
||||
<a-switch v-model="inbound.stream.hysteria.masqueradeSwitch"></a-switch>
|
||||
</a-form-item>
|
||||
<template v-if="inbound.stream.hysteria.masqueradeSwitch">
|
||||
<a-divider :style="{ margin: '5px 0 0' }">Masquerade</a-divider>
|
||||
<a-form-item label="Type">
|
||||
<a-select
|
||||
v-model="inbound.stream.hysteria.masquerade.type"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
>
|
||||
<a-select-option value="file">File</a-select-option>
|
||||
<a-select-option value="proxy">Proxy</a-select-option>
|
||||
<a-select-option value="string">String</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
label="Dir"
|
||||
v-if="inbound.stream.hysteria.masquerade.type === 'file'"
|
||||
>
|
||||
<a-input v-model.trim="inbound.stream.hysteria.masquerade.dir"></a-input>
|
||||
</a-form-item>
|
||||
<template v-if="inbound.stream.hysteria.masquerade.type === 'proxy'">
|
||||
<a-form-item label="URL">
|
||||
<a-input
|
||||
v-model.trim="inbound.stream.hysteria.masquerade.url"
|
||||
></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Rewrite Host">
|
||||
<a-switch
|
||||
v-model="inbound.stream.hysteria.masquerade.rewriteHost"
|
||||
></a-switch>
|
||||
</a-form-item>
|
||||
<a-form-item label="Insecure">
|
||||
<a-switch
|
||||
v-model="inbound.stream.hysteria.masquerade.insecure"
|
||||
></a-switch>
|
||||
</a-form-item>
|
||||
</template>
|
||||
<template v-if="inbound.stream.hysteria.masquerade.type === 'string'">
|
||||
<a-form-item label="Content">
|
||||
<a-input
|
||||
v-model.trim="inbound.stream.hysteria.masquerade.content"
|
||||
></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.inbounds.stream.tcp.requestHeader" }}'>
|
||||
<a-button
|
||||
size="small"
|
||||
@click="inbound.stream.hysteria.masquerade.addHeader('', '')"
|
||||
>+</a-button
|
||||
>
|
||||
</a-form-item>
|
||||
<a-form-item :wrapper-col="{span:24}">
|
||||
<a-input-group
|
||||
compact
|
||||
v-for="(header, index) in inbound.stream.hysteria.masquerade.headers"
|
||||
>
|
||||
<a-input
|
||||
style="width: 50%"
|
||||
v-model.trim="header.name"
|
||||
placeholder='{{ i18n "pages.inbounds.stream.general.name"}}'
|
||||
>
|
||||
<template slot="addonBefore" style="margin: 0"
|
||||
>[[ index+1 ]]</template
|
||||
>
|
||||
</a-input>
|
||||
<a-input
|
||||
style="width: 50%"
|
||||
v-model.trim="header.value"
|
||||
placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'
|
||||
>
|
||||
<a-button
|
||||
slot="addonAfter"
|
||||
size="small"
|
||||
@click="inbound.stream.hysteria.masquerade.removeHeader(index)"
|
||||
>-</a-button
|
||||
>
|
||||
</a-input>
|
||||
</a-input-group>
|
||||
</a-form-item>
|
||||
<a-form-item label="Status Code">
|
||||
<a-input-number
|
||||
v-model.number="inbound.stream.hysteria.masquerade.statusCode"
|
||||
></a-input-number>
|
||||
</a-form-item>
|
||||
</template>
|
||||
</template>
|
||||
</a-form>
|
||||
{{end}}
|
||||
@@ -1,32 +1,46 @@
|
||||
{{define "form/streamKCP"}}
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }">
|
||||
<a-form-item label='MTU'>
|
||||
<a-input-number v-model.number="inbound.stream.kcp.mtu" :min="576"
|
||||
:max="1460"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label='TTI (ms)'>
|
||||
<a-input-number v-model.number="inbound.stream.kcp.tti" :min="10"
|
||||
:max="100"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label='Uplink (MB/s)'>
|
||||
<a-input-number v-model.number="inbound.stream.kcp.upCap"
|
||||
:min="0"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label='Downlink (MB/s)'>
|
||||
<a-input-number v-model.number="inbound.stream.kcp.downCap"
|
||||
:min="0"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label='Congestion'>
|
||||
<a-switch v-model="inbound.stream.kcp.congestion"></a-switch>
|
||||
</a-form-item>
|
||||
<a-form-item label='Read Buffer (MB)'>
|
||||
<a-input-number v-model.number="inbound.stream.kcp.readBuffer"
|
||||
:min="0"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label='Write Buffer (MB)'>
|
||||
<a-input-number v-model.number="inbound.stream.kcp.writeBuffer"
|
||||
:min="0"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form
|
||||
:colon="false"
|
||||
:label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }"
|
||||
>
|
||||
<a-form-item label="MTU">
|
||||
<a-input-number
|
||||
v-model.number="inbound.stream.kcp.mtu"
|
||||
:min="576"
|
||||
:max="1460"
|
||||
></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="TTI (ms)">
|
||||
<a-input-number
|
||||
v-model.number="inbound.stream.kcp.tti"
|
||||
:min="10"
|
||||
:max="100"
|
||||
></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="Uplink (MB/s)">
|
||||
<a-input-number
|
||||
v-model.number="inbound.stream.kcp.upCap"
|
||||
:min="0"
|
||||
></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="Downlink (MB/s)">
|
||||
<a-input-number
|
||||
v-model.number="inbound.stream.kcp.downCap"
|
||||
:min="0"
|
||||
></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="CWND Multiplier">
|
||||
<a-input-number
|
||||
v-model.number="inbound.stream.kcp.cwndMultiplier"
|
||||
:min="0"
|
||||
></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="Max Sending Window">
|
||||
<a-input-number
|
||||
v-model.number="inbound.stream.kcp.maxSendingWindow"
|
||||
:min="0"
|
||||
></a-input-number>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
{{end}}
|
||||
|
||||
@@ -1,59 +1,66 @@
|
||||
{{define "form/streamSettings"}}
|
||||
<!-- select stream network -->
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }">
|
||||
<a-form-item label='{{ i18n "transmission" }}'>
|
||||
<a-select v-model="inbound.stream.network" :style="{ width: '75%' }"
|
||||
@change="streamNetworkChange"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option value="tcp">TCP (RAW)</a-select-option>
|
||||
<a-select-option value="kcp">mKCP</a-select-option>
|
||||
<a-select-option value="ws">WebSocket</a-select-option>
|
||||
<a-select-option value="grpc">gRPC</a-select-option>
|
||||
<a-select-option value="httpupgrade">HTTPUpgrade</a-select-option>
|
||||
<a-select-option value="xhttp">XHTTP</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form
|
||||
:colon="false"
|
||||
:label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }"
|
||||
v-if="inbound.protocol != Protocols.HYSTERIA"
|
||||
>
|
||||
<a-form-item label='{{ i18n "transmission" }}'>
|
||||
<a-select
|
||||
v-model="inbound.stream.network"
|
||||
:style="{ width: '75%' }"
|
||||
@change="streamNetworkChange"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
>
|
||||
<a-select-option value="tcp">TCP (RAW)</a-select-option>
|
||||
<a-select-option value="kcp">mKCP</a-select-option>
|
||||
<a-select-option value="ws">WebSocket</a-select-option>
|
||||
<a-select-option value="grpc">gRPC</a-select-option>
|
||||
<a-select-option value="httpupgrade">HTTPUpgrade</a-select-option>
|
||||
<a-select-option value="xhttp">XHTTP</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<!-- tcp -->
|
||||
<template v-if="inbound.stream.network === 'tcp'">
|
||||
{{template "form/streamTCP"}}
|
||||
{{template "form/streamTCP"}}
|
||||
</template>
|
||||
|
||||
<!-- kcp -->
|
||||
<template v-if="inbound.stream.network === 'kcp'">
|
||||
{{template "form/streamKCP"}}
|
||||
{{template "form/streamKCP"}}
|
||||
</template>
|
||||
|
||||
<!-- ws -->
|
||||
<template v-if="inbound.stream.network === 'ws'">
|
||||
{{template "form/streamWS"}}
|
||||
{{template "form/streamWS"}}
|
||||
</template>
|
||||
|
||||
<!-- grpc -->
|
||||
<template v-if="inbound.stream.network === 'grpc'">
|
||||
{{template "form/streamGRPC"}}
|
||||
{{template "form/streamGRPC"}}
|
||||
</template>
|
||||
|
||||
<!-- hysteria -->
|
||||
<template v-if="inbound.stream.network === 'hysteria'">
|
||||
{{template "form/streamHysteria"}}
|
||||
</template>
|
||||
|
||||
<!-- httpupgrade -->
|
||||
<template v-if="inbound.stream.network === 'httpupgrade'">
|
||||
{{template "form/streamHTTPUpgrade"}}
|
||||
{{template "form/streamHTTPUpgrade"}}
|
||||
</template>
|
||||
|
||||
<!-- xhttp -->
|
||||
<template v-if="inbound.stream.network === 'xhttp'">
|
||||
{{template "form/streamXHTTP"}}
|
||||
{{template "form/streamXHTTP"}}
|
||||
</template>
|
||||
|
||||
<!-- sockopt -->
|
||||
<template>
|
||||
{{template "form/streamSockopt"}}
|
||||
</template>
|
||||
<template> {{template "form/streamSockopt"}} </template>
|
||||
|
||||
<!-- finalmask - only for TCP, WS, HTTPUpgrade, XHTTP, mKCP -->
|
||||
<template
|
||||
v-if="['tcp', 'ws', 'httpupgrade', 'xhttp', 'kcp'].includes(inbound.stream.network)">
|
||||
{{template "form/streamFinalMask"}}
|
||||
</template>
|
||||
<!-- finalmask -->
|
||||
<template> {{template "form/streamFinalMask"}} </template>
|
||||
{{end}}
|
||||
|
||||
@@ -1,75 +1,119 @@
|
||||
{{define "form/streamSockopt"}}
|
||||
<a-divider :style="{ margin: '5px 0 0' }"></a-divider>
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-form-item label="Sockopt">
|
||||
<a-switch v-model="inbound.stream.sockoptSwitch"></a-switch>
|
||||
<a-form
|
||||
:colon="false"
|
||||
:label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }"
|
||||
>
|
||||
<a-form-item label="Sockopt">
|
||||
<a-switch v-model="inbound.stream.sockoptSwitch"></a-switch>
|
||||
</a-form-item>
|
||||
<template v-if="inbound.stream.sockoptSwitch">
|
||||
<a-form-item label="Route Mark">
|
||||
<a-input-number
|
||||
v-model.number="inbound.stream.sockopt.mark"
|
||||
:min="0"
|
||||
></a-input-number>
|
||||
</a-form-item>
|
||||
<template v-if="inbound.stream.sockoptSwitch">
|
||||
<a-form-item label="Route Mark">
|
||||
<a-input-number v-model.number="inbound.stream.sockopt.mark" :min="0"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="TCP Keep Alive Interval">
|
||||
<a-input-number v-model.number="inbound.stream.sockopt.tcpKeepAliveInterval" :min="0"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="TCP Keep Alive Idle">
|
||||
<a-input-number v-model.number="inbound.stream.sockopt.tcpKeepAliveIdle" :min="0"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="TCP Max Seg">
|
||||
<a-input-number v-model.number="inbound.stream.sockopt.tcpMaxSeg" :min="0"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="TCP User Timeout">
|
||||
<a-input-number v-model.number="inbound.stream.sockopt.tcpUserTimeout" :min="0"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="TCP Window Clamp">
|
||||
<a-input-number v-model.number="inbound.stream.sockopt.tcpWindowClamp" :min="0"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="Proxy Protocol">
|
||||
<a-switch v-model="inbound.stream.sockopt.acceptProxyProtocol"></a-switch>
|
||||
</a-form-item>
|
||||
<a-form-item label="TCP Fast Open">
|
||||
<a-switch v-model.trim="inbound.stream.sockopt.tcpFastOpen"></a-switch>
|
||||
</a-form-item>
|
||||
<a-form-item label="Multipath TCP">
|
||||
<a-switch v-model.trim="inbound.stream.sockopt.tcpMptcp"></a-switch>
|
||||
</a-form-item>
|
||||
<a-form-item label="Penetrate">
|
||||
<a-switch v-model.trim="inbound.stream.sockopt.penetrate"></a-switch>
|
||||
</a-form-item>
|
||||
<a-form-item label="V6 Only">
|
||||
<a-switch v-model.trim="inbound.stream.sockopt.V6Only"></a-switch>
|
||||
</a-form-item>
|
||||
<a-form-item label='Domain Strategy'>
|
||||
<a-select v-model="inbound.stream.sockopt.domainStrategy" :style="{ width: '50%' }" :dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option v-for="key in DOMAIN_STRATEGY_OPTION" :value="key">[[ key ]]</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label='TCP Congestion'>
|
||||
<a-select v-model="inbound.stream.sockopt.tcpcongestion" :style="{ width: '50%' }" :dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option v-for="key in TCP_CONGESTION_OPTION" :value="key">[[ key ]]</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="TProxy">
|
||||
<a-select v-model="inbound.stream.sockopt.tproxy" :style="{ width: '50%' }" :dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option value="off">Off</a-select-option>
|
||||
<a-select-option value="redirect">Redirect</a-select-option>
|
||||
<a-select-option value="tproxy">TProxy</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Dialer Proxy">
|
||||
<a-input v-model="inbound.stream.sockopt.dialerProxy"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Interface Name">
|
||||
<a-input v-model="inbound.stream.sockopt.interfaceName"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Trusted X-Forwarded-For">
|
||||
<a-select mode="tags" v-model="inbound.stream.sockopt.trustedXForwardedFor" :style="{ width: '100%' }"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option value="CF-Connecting-IP">CF-Connecting-IP</a-select-option>
|
||||
<a-select-option value="X-Real-IP">X-Real-IP</a-select-option>
|
||||
<a-select-option value="True-Client-IP">True-Client-IP</a-select-option>
|
||||
<a-select-option value="X-Client-IP">X-Client-IP</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</template>
|
||||
<a-form-item label="TCP Keep Alive Interval">
|
||||
<a-input-number
|
||||
v-model.number="inbound.stream.sockopt.tcpKeepAliveInterval"
|
||||
:min="0"
|
||||
></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="TCP Keep Alive Idle">
|
||||
<a-input-number
|
||||
v-model.number="inbound.stream.sockopt.tcpKeepAliveIdle"
|
||||
:min="0"
|
||||
></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="TCP Max Seg">
|
||||
<a-input-number
|
||||
v-model.number="inbound.stream.sockopt.tcpMaxSeg"
|
||||
:min="0"
|
||||
></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="TCP User Timeout">
|
||||
<a-input-number
|
||||
v-model.number="inbound.stream.sockopt.tcpUserTimeout"
|
||||
:min="0"
|
||||
></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="TCP Window Clamp">
|
||||
<a-input-number
|
||||
v-model.number="inbound.stream.sockopt.tcpWindowClamp"
|
||||
:min="0"
|
||||
></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="Proxy Protocol">
|
||||
<a-switch v-model="inbound.stream.sockopt.acceptProxyProtocol"></a-switch>
|
||||
</a-form-item>
|
||||
<a-form-item label="TCP Fast Open">
|
||||
<a-switch v-model.trim="inbound.stream.sockopt.tcpFastOpen"></a-switch>
|
||||
</a-form-item>
|
||||
<a-form-item label="Multipath TCP">
|
||||
<a-switch v-model.trim="inbound.stream.sockopt.tcpMptcp"></a-switch>
|
||||
</a-form-item>
|
||||
<a-form-item label="Penetrate">
|
||||
<a-switch v-model.trim="inbound.stream.sockopt.penetrate"></a-switch>
|
||||
</a-form-item>
|
||||
<a-form-item label="V6 Only">
|
||||
<a-switch v-model.trim="inbound.stream.sockopt.V6Only"></a-switch>
|
||||
</a-form-item>
|
||||
<a-form-item label="Domain Strategy">
|
||||
<a-select
|
||||
v-model="inbound.stream.sockopt.domainStrategy"
|
||||
:style="{ width: '50%' }"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
>
|
||||
<a-select-option v-for="key in DOMAIN_STRATEGY_OPTION" :value="key"
|
||||
>[[ key ]]</a-select-option
|
||||
>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="TCP Congestion">
|
||||
<a-select
|
||||
v-model="inbound.stream.sockopt.tcpcongestion"
|
||||
:style="{ width: '50%' }"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
>
|
||||
<a-select-option v-for="key in TCP_CONGESTION_OPTION" :value="key"
|
||||
>[[ key ]]</a-select-option
|
||||
>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="TProxy">
|
||||
<a-select
|
||||
v-model="inbound.stream.sockopt.tproxy"
|
||||
:style="{ width: '50%' }"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
>
|
||||
<a-select-option value="off">Off</a-select-option>
|
||||
<a-select-option value="redirect">Redirect</a-select-option>
|
||||
<a-select-option value="tproxy">TProxy</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Dialer Proxy">
|
||||
<a-input v-model="inbound.stream.sockopt.dialerProxy"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Interface Name">
|
||||
<a-input v-model="inbound.stream.sockopt.interfaceName"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Trusted X-Forwarded-For">
|
||||
<a-select
|
||||
mode="tags"
|
||||
v-model="inbound.stream.sockopt.trustedXForwardedFor"
|
||||
:style="{ width: '100%' }"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
>
|
||||
<a-select-option value="CF-Connecting-IP"
|
||||
>CF-Connecting-IP</a-select-option
|
||||
>
|
||||
<a-select-option value="X-Real-IP">X-Real-IP</a-select-option>
|
||||
<a-select-option value="True-Client-IP">True-Client-IP</a-select-option>
|
||||
<a-select-option value="X-Client-IP">X-Client-IP</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</template>
|
||||
</a-form>
|
||||
{{end}}
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
{{define "form/streamTCP"}}
|
||||
<!-- tcp type -->
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-form
|
||||
:colon="false"
|
||||
:label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }"
|
||||
>
|
||||
<a-form-item label="Proxy Protocol" v-if="inbound.canEnableTls()">
|
||||
<a-switch v-model="inbound.stream.tcp.acceptProxyProtocol"></a-switch>
|
||||
</a-form-item>
|
||||
<a-form-item label='HTTP {{ i18n "camouflage" }}'>
|
||||
<a-switch :checked="inbound.stream.tcp.type === 'http'" @change="checked => inbound.stream.tcp.type = checked ? 'http' : 'none'"></a-switch>
|
||||
<a-switch
|
||||
:checked="inbound.stream.tcp.type === 'http'"
|
||||
@change="checked => inbound.stream.tcp.type = checked ? 'http' : 'none'"
|
||||
></a-switch>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<a-form v-if="inbound.stream.tcp.type === 'http'" :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-form
|
||||
v-if="inbound.stream.tcp.type === 'http'"
|
||||
:colon="false"
|
||||
:label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }"
|
||||
>
|
||||
<!-- tcp request -->
|
||||
<a-divider :style="{ margin: '0' }">{{ i18n "pages.inbounds.stream.general.request" }}</a-divider>
|
||||
<a-divider :style="{ margin: '0' }"
|
||||
>{{ i18n "pages.inbounds.stream.general.request" }}</a-divider
|
||||
>
|
||||
<a-form-item label='{{ i18n "pages.inbounds.stream.tcp.version" }}'>
|
||||
<a-input v-model.trim="inbound.stream.tcp.request.version"></a-input>
|
||||
</a-form-item>
|
||||
@@ -19,31 +33,66 @@
|
||||
<a-input v-model.trim="inbound.stream.tcp.request.method"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<template slot="label">{{ i18n "pages.inbounds.stream.tcp.path" }}
|
||||
<a-button icon="plus" size="small" @click="inbound.stream.tcp.request.addPath('/')"></a-button>
|
||||
<template slot="label"
|
||||
>{{ i18n "pages.inbounds.stream.tcp.path" }}
|
||||
<a-button
|
||||
icon="plus"
|
||||
size="small"
|
||||
@click="inbound.stream.tcp.request.addPath('/')"
|
||||
></a-button>
|
||||
</template>
|
||||
<template v-for="(path, index) in inbound.stream.tcp.request.path">
|
||||
<a-input v-model.trim="inbound.stream.tcp.request.path[index]">
|
||||
<a-button icon="minus" size="small" slot="addonAfter" @click="inbound.stream.tcp.request.removePath(index)" v-if="inbound.stream.tcp.request.path.length>1"></a-button>
|
||||
<a-button
|
||||
icon="minus"
|
||||
size="small"
|
||||
slot="addonAfter"
|
||||
@click="inbound.stream.tcp.request.removePath(index)"
|
||||
v-if="inbound.stream.tcp.request.path.length>1"
|
||||
></a-button>
|
||||
</a-input>
|
||||
</template>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.inbounds.stream.tcp.requestHeader" }}'>
|
||||
<a-button icon="plus" size="small" @click="inbound.stream.tcp.request.addHeader('Host', '')"></a-button>
|
||||
<a-button
|
||||
icon="plus"
|
||||
size="small"
|
||||
@click="inbound.stream.tcp.request.addHeader('Host', '')"
|
||||
></a-button>
|
||||
</a-form-item>
|
||||
<a-form-item :wrapper-col="{span:24}">
|
||||
<a-input-group compact v-for="(header, index) in inbound.stream.tcp.request.headers">
|
||||
<a-input :style="{ width: '50%' }" v-model.trim="header.name" placeholder='{{ i18n "pages.inbounds.stream.general.name" }}'>
|
||||
<template slot="addonBefore" :style="{ margin: '0' }">[[ index+1 ]]</template>
|
||||
<a-input-group
|
||||
compact
|
||||
v-for="(header, index) in inbound.stream.tcp.request.headers"
|
||||
>
|
||||
<a-input
|
||||
:style="{ width: '50%' }"
|
||||
v-model.trim="header.name"
|
||||
placeholder='{{ i18n "pages.inbounds.stream.general.name" }}'
|
||||
>
|
||||
<template slot="addonBefore" :style="{ margin: '0' }"
|
||||
>[[ index+1 ]]</template
|
||||
>
|
||||
</a-input>
|
||||
<a-input :style="{ width: '50%' }" v-model.trim="header.value" placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'>
|
||||
<a-button icon="minus" slot="addonAfter" size="small" @click="inbound.stream.tcp.request.removeHeader(index)"></a-button>
|
||||
<a-input
|
||||
:style="{ width: '50%' }"
|
||||
v-model.trim="header.value"
|
||||
placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'
|
||||
>
|
||||
<a-button
|
||||
icon="minus"
|
||||
slot="addonAfter"
|
||||
size="small"
|
||||
@click="inbound.stream.tcp.request.removeHeader(index)"
|
||||
></a-button>
|
||||
</a-input>
|
||||
</a-input-group>
|
||||
</a-form-item>
|
||||
|
||||
<!-- tcp response -->
|
||||
<a-divider :style="{ margin: '0' }">{{ i18n "pages.inbounds.stream.general.response" }}</a-divider>
|
||||
<a-divider :style="{ margin: '0' }"
|
||||
>{{ i18n "pages.inbounds.stream.general.response" }}</a-divider
|
||||
>
|
||||
<a-form-item label='{{ i18n "pages.inbounds.stream.tcp.version" }}'>
|
||||
<a-input v-model.trim="inbound.stream.tcp.response.version"></a-input>
|
||||
</a-form-item>
|
||||
@@ -54,16 +103,37 @@
|
||||
<a-input v-model.trim="inbound.stream.tcp.response.reason"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.inbounds.stream.tcp.responseHeader" }}'>
|
||||
<a-button icon="plus" size="small" @click="inbound.stream.tcp.response.addHeader('Content-Type', 'application/octet-stream')"></a-button>
|
||||
<a-button
|
||||
icon="plus"
|
||||
size="small"
|
||||
@click="inbound.stream.tcp.response.addHeader('Content-Type', 'application/octet-stream')"
|
||||
></a-button>
|
||||
</a-form-item>
|
||||
<a-form-item :wrapper-col="{span:24}">
|
||||
<a-input-group compact v-for="(header, index) in inbound.stream.tcp.response.headers">
|
||||
<a-input :style="{ width: '50%' }" v-model.trim="header.name" placeholder='{{ i18n "pages.inbounds.stream.general.name" }}'>
|
||||
<template slot="addonBefore" :style="{ margin: '0' }">[[ index+1 ]]</template>
|
||||
<a-input-group
|
||||
compact
|
||||
v-for="(header, index) in inbound.stream.tcp.response.headers"
|
||||
>
|
||||
<a-input
|
||||
:style="{ width: '50%' }"
|
||||
v-model.trim="header.name"
|
||||
placeholder='{{ i18n "pages.inbounds.stream.general.name" }}'
|
||||
>
|
||||
<template slot="addonBefore" :style="{ margin: '0' }"
|
||||
>[[ index+1 ]]</template
|
||||
>
|
||||
</a-input>
|
||||
<a-input :style="{ width: '50%' }" v-model.trim="header.value" placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'>
|
||||
<a-input
|
||||
:style="{ width: '50%' }"
|
||||
v-model.trim="header.value"
|
||||
placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'
|
||||
>
|
||||
<template slot="addonAfter">
|
||||
<a-button icon="minus" size="small" @click="inbound.stream.tcp.response.removeHeader(index)"></a-button>
|
||||
<a-button
|
||||
icon="minus"
|
||||
size="small"
|
||||
@click="inbound.stream.tcp.response.removeHeader(index)"
|
||||
></a-button>
|
||||
</template>
|
||||
</a-input>
|
||||
</a-input-group>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{{define "form/streamWS"}}
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-form
|
||||
:colon="false"
|
||||
:label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }"
|
||||
>
|
||||
<a-form-item label="Proxy Protocol">
|
||||
<a-switch v-model="inbound.stream.ws.acceptProxyProtocol"></a-switch>
|
||||
</a-form-item>
|
||||
@@ -9,19 +13,41 @@
|
||||
<a-form-item label='{{ i18n "path" }}'>
|
||||
<a-input v-model.trim="inbound.stream.ws.path"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='Heartbeat Period'>
|
||||
<a-input-number v-model.number="inbound.stream.ws.heartbeatPeriod" :min="0"></a-input-number>
|
||||
<a-form-item label="Heartbeat Period">
|
||||
<a-input-number
|
||||
v-model.number="inbound.stream.ws.heartbeatPeriod"
|
||||
:min="0"
|
||||
></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.inbounds.stream.tcp.requestHeader" }}'>
|
||||
<a-button icon="plus" size="small" @click="inbound.stream.ws.addHeader('', '')"></a-button>
|
||||
<a-button
|
||||
icon="plus"
|
||||
size="small"
|
||||
@click="inbound.stream.ws.addHeader('', '')"
|
||||
></a-button>
|
||||
</a-form-item>
|
||||
<a-form-item :wrapper-col="{span:24}">
|
||||
<a-input-group compact v-for="(header, index) in inbound.stream.ws.headers">
|
||||
<a-input :style="{ width: '50%' }" v-model.trim="header.name" placeholder='{{ i18n "pages.inbounds.stream.general.name"}}'>
|
||||
<template slot="addonBefore" :style="{ margin: '0' }">[[ index+1 ]]</template>
|
||||
<a-input
|
||||
:style="{ width: '50%' }"
|
||||
v-model.trim="header.name"
|
||||
placeholder='{{ i18n "pages.inbounds.stream.general.name"}}'
|
||||
>
|
||||
<template slot="addonBefore" :style="{ margin: '0' }"
|
||||
>[[ index+1 ]]</template
|
||||
>
|
||||
</a-input>
|
||||
<a-input :style="{ width: '50%' }" v-model.trim="header.value" placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'>
|
||||
<a-button icon="minus" slot="addonAfter" size="small" @click="inbound.stream.ws.removeHeader(index)"></a-button>
|
||||
<a-input
|
||||
:style="{ width: '50%' }"
|
||||
v-model.trim="header.value"
|
||||
placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'
|
||||
>
|
||||
<a-button
|
||||
icon="minus"
|
||||
slot="addonAfter"
|
||||
size="small"
|
||||
@click="inbound.stream.ws.removeHeader(index)"
|
||||
></a-button>
|
||||
</a-input>
|
||||
</a-input-group>
|
||||
</a-form-item>
|
||||
|
||||
@@ -1,147 +1,212 @@
|
||||
{{define "form/streamXHTTP"}}
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }">
|
||||
<a-form-item label='{{ i18n "host" }}'>
|
||||
<a-input v-model.trim="inbound.stream.xhttp.host"></a-input>
|
||||
<a-form
|
||||
:colon="false"
|
||||
:label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }"
|
||||
>
|
||||
<a-form-item label='{{ i18n "host" }}'>
|
||||
<a-input v-model.trim="inbound.stream.xhttp.host"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "path" }}'>
|
||||
<a-input v-model.trim="inbound.stream.xhttp.path"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.inbounds.stream.tcp.requestHeader" }}'>
|
||||
<a-button
|
||||
icon="plus"
|
||||
size="small"
|
||||
@click="inbound.stream.xhttp.addHeader('', '')"
|
||||
></a-button>
|
||||
</a-form-item>
|
||||
<a-form-item :wrapper-col="{span:24}">
|
||||
<a-input-group
|
||||
compact
|
||||
v-for="(header, index) in inbound.stream.xhttp.headers"
|
||||
>
|
||||
<a-input
|
||||
:style="{ width: '50%' }"
|
||||
v-model.trim="header.name"
|
||||
placeholder='{{ i18n "pages.inbounds.stream.general.name"}}'
|
||||
>
|
||||
<template slot="addonBefore" :style="{ margin: '0' }"
|
||||
>[[ index+1 ]]</template
|
||||
>
|
||||
</a-input>
|
||||
<a-input
|
||||
:style="{ width: '50%' }"
|
||||
v-model.trim="header.value"
|
||||
placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'
|
||||
>
|
||||
<a-button
|
||||
icon="minus"
|
||||
slot="addonAfter"
|
||||
size="small"
|
||||
@click="inbound.stream.xhttp.removeHeader(index)"
|
||||
></a-button>
|
||||
</a-input>
|
||||
</a-input-group>
|
||||
</a-form-item>
|
||||
<a-form-item label="Mode">
|
||||
<a-select
|
||||
v-model="inbound.stream.xhttp.mode"
|
||||
:style="{ width: '50%' }"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
>
|
||||
<a-select-option v-for="key in MODE_OPTION" :value="key"
|
||||
>[[ key ]]</a-select-option
|
||||
>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
label="Max Buffered Upload"
|
||||
v-if="inbound.stream.xhttp.mode === 'packet-up'"
|
||||
>
|
||||
<a-input-number
|
||||
v-model.number="inbound.stream.xhttp.scMaxBufferedPosts"
|
||||
></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
label="Max Upload Size (Byte)"
|
||||
v-if="inbound.stream.xhttp.mode === 'packet-up'"
|
||||
>
|
||||
<a-input v-model.trim="inbound.stream.xhttp.scMaxEachPostBytes"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
label="Stream-Up Server"
|
||||
v-if="inbound.stream.xhttp.mode === 'stream-up'"
|
||||
>
|
||||
<a-input v-model.trim="inbound.stream.xhttp.scStreamUpServerSecs"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Padding Bytes">
|
||||
<a-input v-model.trim="inbound.stream.xhttp.xPaddingBytes"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Padding Obfs Mode">
|
||||
<a-switch v-model="inbound.stream.xhttp.xPaddingObfsMode"></a-switch>
|
||||
</a-form-item>
|
||||
<template v-if="inbound.stream.xhttp.xPaddingObfsMode">
|
||||
<a-form-item label="Padding Key">
|
||||
<a-input
|
||||
v-model.trim="inbound.stream.xhttp.xPaddingKey"
|
||||
placeholder="x_padding"
|
||||
></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "path" }}'>
|
||||
<a-input v-model.trim="inbound.stream.xhttp.path"></a-input>
|
||||
<a-form-item label="Padding Header">
|
||||
<a-input
|
||||
v-model.trim="inbound.stream.xhttp.xPaddingHeader"
|
||||
placeholder="X-Padding"
|
||||
></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.inbounds.stream.tcp.requestHeader" }}'>
|
||||
<a-button icon="plus" size="small"
|
||||
@click="inbound.stream.xhttp.addHeader('', '')"></a-button>
|
||||
<a-form-item label="Padding Placement">
|
||||
<a-select
|
||||
v-model="inbound.stream.xhttp.xPaddingPlacement"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
>
|
||||
<a-select-option value>Default (queryInHeader)</a-select-option>
|
||||
<a-select-option value="queryInHeader">queryInHeader</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-form-item>
|
||||
<a-form-item :wrapper-col="{span:24}">
|
||||
<a-input-group compact
|
||||
v-for="(header, index) in inbound.stream.xhttp.headers">
|
||||
<a-input :style="{ width: '50%' }" v-model.trim="header.name"
|
||||
placeholder='{{ i18n "pages.inbounds.stream.general.name"}}'>
|
||||
<template slot="addonBefore" :style="{ margin: '0' }">[[ index+1
|
||||
]]</template>
|
||||
</a-input>
|
||||
<a-input :style="{ width: '50%' }" v-model.trim="header.value"
|
||||
placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'>
|
||||
<a-button icon="minus" slot="addonAfter" size="small"
|
||||
@click="inbound.stream.xhttp.removeHeader(index)"></a-button>
|
||||
</a-input>
|
||||
</a-input-group>
|
||||
</a-form-item>
|
||||
<a-form-item label='Mode'>
|
||||
<a-select v-model="inbound.stream.xhttp.mode" :style="{ width: '50%' }"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option v-for="key in MODE_OPTION" :value="key">[[ key
|
||||
]]</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Max Buffered Upload"
|
||||
v-if="inbound.stream.xhttp.mode === 'packet-up'">
|
||||
<a-input-number
|
||||
v-model.number="inbound.stream.xhttp.scMaxBufferedPosts"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="Max Upload Size (Byte)"
|
||||
v-if="inbound.stream.xhttp.mode === 'packet-up'">
|
||||
<a-input
|
||||
v-model.trim="inbound.stream.xhttp.scMaxEachPostBytes"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Stream-Up Server"
|
||||
v-if="inbound.stream.xhttp.mode === 'stream-up'">
|
||||
<a-input
|
||||
v-model.trim="inbound.stream.xhttp.scStreamUpServerSecs"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Padding Bytes">
|
||||
<a-input v-model.trim="inbound.stream.xhttp.xPaddingBytes"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Padding Obfs Mode">
|
||||
<a-switch v-model="inbound.stream.xhttp.xPaddingObfsMode"></a-switch>
|
||||
</a-form-item>
|
||||
<template v-if="inbound.stream.xhttp.xPaddingObfsMode">
|
||||
<a-form-item label="Padding Key">
|
||||
<a-input v-model.trim="inbound.stream.xhttp.xPaddingKey"
|
||||
placeholder="x_padding"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Padding Header">
|
||||
<a-input v-model.trim="inbound.stream.xhttp.xPaddingHeader"
|
||||
placeholder="X-Padding"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Padding Placement">
|
||||
<a-select v-model="inbound.stream.xhttp.xPaddingPlacement"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option value>Default (queryInHeader)</a-select-option>
|
||||
<a-select-option
|
||||
value="queryInHeader">queryInHeader</a-select-option>
|
||||
<a-select-option value="header">header</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Padding Method">
|
||||
<a-select v-model="inbound.stream.xhttp.xPaddingMethod"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option value>Default (repeat-x)</a-select-option>
|
||||
<a-select-option value="repeat-x">repeat-x</a-select-option>
|
||||
<a-select-option value="tokenish">tokenish</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</template>
|
||||
<a-form-item label="Uplink HTTP Method">
|
||||
<a-select v-model="inbound.stream.xhttp.uplinkHTTPMethod"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option value>Default (POST)</a-select-option>
|
||||
<a-select-option value="POST">POST</a-select-option>
|
||||
<a-select-option value="PUT">PUT</a-select-option>
|
||||
<a-select-option value="GET">GET (packet-up only)</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Session Placement">
|
||||
<a-select v-model="inbound.stream.xhttp.sessionPlacement"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option value>Default (path)</a-select-option>
|
||||
<a-select-option value="path">path</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-form-item>
|
||||
<a-form-item label="Session Key"
|
||||
v-if="inbound.stream.xhttp.sessionPlacement && inbound.stream.xhttp.sessionPlacement !== 'path'">
|
||||
<a-input v-model.trim="inbound.stream.xhttp.sessionKey"
|
||||
placeholder="x_session"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Sequence Placement">
|
||||
<a-select v-model="inbound.stream.xhttp.seqPlacement"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option value>Default (path)</a-select-option>
|
||||
<a-select-option value="path">path</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-form-item>
|
||||
<a-form-item label="Sequence Key"
|
||||
v-if="inbound.stream.xhttp.seqPlacement && inbound.stream.xhttp.seqPlacement !== 'path'">
|
||||
<a-input v-model.trim="inbound.stream.xhttp.seqKey"
|
||||
placeholder="x_seq"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Uplink Data Placement"
|
||||
v-if="inbound.stream.xhttp.mode === 'packet-up'">
|
||||
<a-select v-model="inbound.stream.xhttp.uplinkDataPlacement"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option value>Default (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="query">query</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Uplink Data Key"
|
||||
v-if="inbound.stream.xhttp.mode === 'packet-up' && inbound.stream.xhttp.uplinkDataPlacement && inbound.stream.xhttp.uplinkDataPlacement !== 'body'">
|
||||
<a-input v-model.trim="inbound.stream.xhttp.uplinkDataKey"
|
||||
placeholder="x_data"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Uplink Chunk Size"
|
||||
v-if="inbound.stream.xhttp.mode === 'packet-up' && inbound.stream.xhttp.uplinkDataPlacement && inbound.stream.xhttp.uplinkDataPlacement !== 'body'">
|
||||
<a-input-number v-model.number="inbound.stream.xhttp.uplinkChunkSize"
|
||||
:min="0" placeholder="0 (unlimited)"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="No SSE Header">
|
||||
<a-switch v-model="inbound.stream.xhttp.noSSEHeader"></a-switch>
|
||||
<a-form-item label="Padding Method">
|
||||
<a-select
|
||||
v-model="inbound.stream.xhttp.xPaddingMethod"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
>
|
||||
<a-select-option value>Default (repeat-x)</a-select-option>
|
||||
<a-select-option value="repeat-x">repeat-x</a-select-option>
|
||||
<a-select-option value="tokenish">tokenish</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</template>
|
||||
<a-form-item label="Uplink HTTP Method">
|
||||
<a-select
|
||||
v-model="inbound.stream.xhttp.uplinkHTTPMethod"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
>
|
||||
<a-select-option value>Default (POST)</a-select-option>
|
||||
<a-select-option value="POST">POST</a-select-option>
|
||||
<a-select-option value="PUT">PUT</a-select-option>
|
||||
<a-select-option value="GET">GET (packet-up only)</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Session Placement">
|
||||
<a-select
|
||||
v-model="inbound.stream.xhttp.sessionPlacement"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
>
|
||||
<a-select-option value>Default (path)</a-select-option>
|
||||
<a-select-option value="path">path</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-form-item>
|
||||
<a-form-item
|
||||
label="Session Key"
|
||||
v-if="inbound.stream.xhttp.sessionPlacement && inbound.stream.xhttp.sessionPlacement !== 'path'"
|
||||
>
|
||||
<a-input
|
||||
v-model.trim="inbound.stream.xhttp.sessionKey"
|
||||
placeholder="x_session"
|
||||
></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Sequence Placement">
|
||||
<a-select
|
||||
v-model="inbound.stream.xhttp.seqPlacement"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
>
|
||||
<a-select-option value>Default (path)</a-select-option>
|
||||
<a-select-option value="path">path</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-form-item>
|
||||
<a-form-item
|
||||
label="Sequence Key"
|
||||
v-if="inbound.stream.xhttp.seqPlacement && inbound.stream.xhttp.seqPlacement !== 'path'"
|
||||
>
|
||||
<a-input
|
||||
v-model.trim="inbound.stream.xhttp.seqKey"
|
||||
placeholder="x_seq"
|
||||
></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
label="Uplink Data Placement"
|
||||
v-if="inbound.stream.xhttp.mode === 'packet-up'"
|
||||
>
|
||||
<a-select
|
||||
v-model="inbound.stream.xhttp.uplinkDataPlacement"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
>
|
||||
<a-select-option value>Default (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="cookie">cookie</a-select-option>
|
||||
<a-select-option value="query">query</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
label="Uplink Data Key"
|
||||
v-if="inbound.stream.xhttp.mode === 'packet-up' && inbound.stream.xhttp.uplinkDataPlacement && inbound.stream.xhttp.uplinkDataPlacement !== 'body'"
|
||||
>
|
||||
<a-input
|
||||
v-model.trim="inbound.stream.xhttp.uplinkDataKey"
|
||||
placeholder="x_data"
|
||||
></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
label="Uplink Chunk Size"
|
||||
v-if="inbound.stream.xhttp.mode === 'packet-up' && inbound.stream.xhttp.uplinkDataPlacement && inbound.stream.xhttp.uplinkDataPlacement !== 'body'"
|
||||
>
|
||||
<a-input-number
|
||||
v-model.number="inbound.stream.xhttp.uplinkChunkSize"
|
||||
:min="0"
|
||||
placeholder="0 (unlimited)"
|
||||
></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="No SSE Header">
|
||||
<a-switch v-model="inbound.stream.xhttp.noSSEHeader"></a-switch>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
@@ -2,15 +2,18 @@
|
||||
<!-- tls enable -->
|
||||
<a-form v-if="inbound.canEnableTls()" :colon="false"
|
||||
:label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-divider :style="{ margin: '3px 0' }"></a-divider>
|
||||
<a-form-item label='{{ i18n "security" }}'>
|
||||
<a-radio-group v-model="inbound.stream.security" button-style="solid">
|
||||
<a-radio-button value="none">{{ i18n "none" }}</a-radio-button>
|
||||
<a-radio-button v-if="inbound.canEnableReality()"
|
||||
value="reality">Reality</a-radio-button>
|
||||
<a-radio-button value="tls">TLS</a-radio-button>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
<template v-if="inbound.protocol !== Protocols.HYSTERIA">
|
||||
<a-divider :style="{ margin: '3px 0' }"></a-divider>
|
||||
<a-form-item label='{{ i18n "security" }}'>
|
||||
<a-radio-group v-model="inbound.stream.security" button-style="solid">
|
||||
<a-radio-button value="none">{{ i18n "none" }}</a-radio-button>
|
||||
<a-radio-button v-if="inbound.canEnableReality()"
|
||||
value="reality">Reality</a-radio-button>
|
||||
<a-radio-button value="tls">TLS</a-radio-button>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
</template>
|
||||
<template v-else><a-divider style="margin:0;">TLS</a-divider></template>
|
||||
|
||||
<!-- tls settings -->
|
||||
<template v-if="inbound.stream.isTls">
|
||||
|
||||
+446
-189
File diff suppressed because it is too large
Load Diff
+222
-6
@@ -2,11 +2,25 @@
|
||||
{{ template "page/head_end" .}}
|
||||
|
||||
{{ 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-sidebar></a-sidebar>
|
||||
<a-layout id="content-layout">
|
||||
<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>
|
||||
<a-alert type="error" v-if="showAlert && loadingStates.fetched" class="mb-10"
|
||||
message='{{ i18n "secAlertTitle" }}' color="red" description='{{ i18n "secAlertSsl" }}' show-icon closable>
|
||||
@@ -15,9 +29,7 @@
|
||||
<transition name="list" appear>
|
||||
<template>
|
||||
<a-row v-if="!loadingStates.fetched">
|
||||
<a-card class="card-placeholder text-center">
|
||||
<a-spin tip='{{ i18n "loading" }}'></a-spin>
|
||||
</a-card>
|
||||
<div :style="{ minHeight: 'calc(100vh - 120px)' }"></div>
|
||||
</a-row>
|
||||
<a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-else>
|
||||
<a-col>
|
||||
@@ -107,7 +119,7 @@
|
||||
</a-row>
|
||||
</span>
|
||||
<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>
|
||||
<a-badge :text="status.xray.stateMsg" :color="status.xray.color"
|
||||
:class="status.xray.color === 'red' ? 'xray-error-animation' : ''" />
|
||||
@@ -115,7 +127,7 @@
|
||||
</template>
|
||||
</template>
|
||||
<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>
|
||||
<span v-if="!isMobile">{{ i18n "pages.index.logs" }}</span>
|
||||
</a-space>
|
||||
@@ -330,8 +342,65 @@
|
||||
<div class="mt-5 d-flex justify-end"><a-button @click="updateGeofile('')">{{ i18n
|
||||
"pages.index.geofilesUpdateAll" }}</a-button></div>
|
||||
</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-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"
|
||||
:class="themeSwitcher.currentTheme" width="800px" footer="">
|
||||
<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({
|
||||
delimiters: ['[[', ']]'],
|
||||
el: '#app',
|
||||
@@ -895,6 +970,25 @@
|
||||
showAlert: false,
|
||||
showIp: 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: {
|
||||
loading(spinning, tip = '{{ i18n "loading"}}') {
|
||||
@@ -963,6 +1057,128 @@
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
this.$confirm({
|
||||
|
||||
@@ -1,52 +1,75 @@
|
||||
{{define "modals/clientsBulkModal"}}
|
||||
<a-modal id="client-bulk-modal" v-model="clientsBulkModal.visible" :title="clientsBulkModal.title"
|
||||
@ok="clientsBulkModal.ok" :confirm-loading="clientsBulkModal.confirmLoading" :closable="true" :mask-closable="false"
|
||||
:ok-text="clientsBulkModal.okText" cancel-text='{{ i18n "close" }}' :class="themeSwitcher.currentTheme">
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-modal id="client-bulk-modal" v-model="clientsBulkModal.visible"
|
||||
:title="clientsBulkModal.title"
|
||||
@ok="clientsBulkModal.ok" :confirm-loading="clientsBulkModal.confirmLoading"
|
||||
:closable="true" :mask-closable="false"
|
||||
:ok-text="clientsBulkModal.okText" cancel-text='{{ i18n "close" }}'
|
||||
:class="themeSwitcher.currentTheme">
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }">
|
||||
<a-form-item label='{{ i18n "pages.client.method" }}'>
|
||||
<a-select v-model="clientsBulkModal.emailMethod" buttonStyle="solid"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option :value="0">Random</a-select-option>
|
||||
<a-select-option :value="1">Random+Prefix</a-select-option>
|
||||
<a-select-option :value="2">Random+Prefix+Num</a-select-option>
|
||||
<a-select-option :value="3">Random+Prefix+Num+Postfix</a-select-option>
|
||||
<a-select-option
|
||||
:value="3">Random+Prefix+Num+Postfix</a-select-option>
|
||||
<a-select-option :value="4">Prefix+Num+Postfix</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.client.first" }}' v-if="clientsBulkModal.emailMethod>1">
|
||||
<a-input-number v-model.number="clientsBulkModal.firstNum" :min="1"></a-input-number>
|
||||
<a-form-item label='{{ i18n "pages.client.first" }}'
|
||||
v-if="clientsBulkModal.emailMethod>1">
|
||||
<a-input-number v-model.number="clientsBulkModal.firstNum"
|
||||
:min="1"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.client.last" }}' v-if="clientsBulkModal.emailMethod>1">
|
||||
<a-input-number v-model.number="clientsBulkModal.lastNum" :min="clientsBulkModal.firstNum"></a-input-number>
|
||||
<a-form-item label='{{ i18n "pages.client.last" }}'
|
||||
v-if="clientsBulkModal.emailMethod>1">
|
||||
<a-input-number v-model.number="clientsBulkModal.lastNum"
|
||||
:min="clientsBulkModal.firstNum"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.client.prefix" }}' v-if="clientsBulkModal.emailMethod>0">
|
||||
<a-form-item label='{{ i18n "pages.client.prefix" }}'
|
||||
v-if="clientsBulkModal.emailMethod>0">
|
||||
<a-input v-model.trim="clientsBulkModal.emailPrefix"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.client.postfix" }}' v-if="clientsBulkModal.emailMethod>2">
|
||||
<a-form-item label='{{ i18n "pages.client.postfix" }}'
|
||||
v-if="clientsBulkModal.emailMethod>2">
|
||||
<a-input v-model.trim="clientsBulkModal.emailPostfix"></a-input>
|
||||
</a-form-item>
|
||||
<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-form-item label='{{ i18n "pages.client.clientCount" }}'
|
||||
v-if="clientsBulkModal.emailMethod < 2">
|
||||
<a-input-number v-model.number="clientsBulkModal.quantity" :min="1"
|
||||
:max="500"></a-input-number>
|
||||
</a-form-item>
|
||||
<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-option v-for="key in USERS_SECURITY" :value="key">[[ key ]]</a-select-option>
|
||||
<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-option v-for="key in USERS_SECURITY" :value="key">[[
|
||||
key ]]</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label='Flow' v-if="clientsBulkModal.inbound.canEnableTlsFlow()">
|
||||
<a-select v-model="clientsBulkModal.flow" :dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option value="" selected>{{ i18n "none" }}</a-select-option>
|
||||
<a-select-option v-for="key in TLS_FLOW_CONTROL" :value="key">[[ key ]]</a-select-option>
|
||||
<a-form-item label='Flow'
|
||||
v-if="clientsBulkModal.inbound.canEnableTlsFlow()">
|
||||
<a-select v-model="clientsBulkModal.flow"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option value selected>{{ i18n "none"
|
||||
}}</a-select-option>
|
||||
<a-select-option v-for="key in TLS_FLOW_CONTROL" :value="key">[[
|
||||
key ]]</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="app.subSettings?.enable">
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<span>{{ i18n "pages.inbounds.subscriptionDesc" }}</span>
|
||||
<span>{{ i18n "pages.inbounds.subscriptionDesc"
|
||||
}}</span>
|
||||
</template>
|
||||
Subscription
|
||||
<a-icon @click="clientsBulkModal.subId = RandomUtil.randomLowerAndNum(16)" type="sync"></a-icon>
|
||||
<a-icon
|
||||
@click="clientsBulkModal.subId = RandomUtil.randomLowerAndNum(16)"
|
||||
type="sync"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input v-model.trim="clientsBulkModal.subId"></a-input>
|
||||
@@ -61,7 +84,8 @@
|
||||
<a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input-number :style="{ width: '50%' }" v-model.number="clientsBulkModal.tgId" min="0"></a-input-number>
|
||||
<a-input-number :style="{ width: '50%' }"
|
||||
v-model.number="clientsBulkModal.tgId" min="0"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="app.ipLimitEnable">
|
||||
<template slot="label">
|
||||
@@ -73,7 +97,8 @@
|
||||
<a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input-number v-model.number="clientsBulkModal.limitIp" min="0"></a-input-number>
|
||||
<a-input-number v-model.number="clientsBulkModal.limitIp"
|
||||
min="0"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<template slot="label">
|
||||
@@ -85,29 +110,38 @@
|
||||
<a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input-number v-model.number="clientsBulkModal.totalGB" :min="0"></a-input-number>
|
||||
<a-input-number v-model.number="clientsBulkModal.totalGB"
|
||||
:min="0"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.client.delayedStart" }}'>
|
||||
<a-switch v-model="clientsBulkModal.delayedStart" @click="clientsBulkModal.expiryTime=0"></a-switch>
|
||||
<a-switch v-model="clientsBulkModal.delayedStart"
|
||||
@click="clientsBulkModal.expiryTime=0"></a-switch>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.client.expireDays" }}' v-if="clientsBulkModal.delayedStart">
|
||||
<a-input-number v-model.number="delayedExpireDays" :min="0"></a-input-number>
|
||||
<a-form-item label='{{ i18n "pages.client.expireDays" }}'
|
||||
v-if="clientsBulkModal.delayedStart">
|
||||
<a-input-number v-model.number="delayedExpireDays"
|
||||
:min="0"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item v-else>
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<span>{{ i18n "pages.inbounds.leaveBlankToNeverExpire" }}</span>
|
||||
<span>{{ i18n "pages.inbounds.leaveBlankToNeverExpire"
|
||||
}}</span>
|
||||
</template>
|
||||
{{ i18n "pages.inbounds.expireDate" }}
|
||||
<a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-date-picker v-if="datepicker == 'gregorian'" :show-time="{ format: 'HH:mm:ss' }"
|
||||
format="YYYY-MM-DD HH:mm:ss" :dropdown-class-name="themeSwitcher.currentTheme"
|
||||
<a-date-picker v-if="datepicker == 'gregorian'"
|
||||
:show-time="{ format: 'HH:mm:ss' }"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
v-model="clientsBulkModal.expiryTime"></a-date-picker>
|
||||
<a-persian-datepicker v-else placeholder='{{ i18n "pages.settings.datepickerPlaceholder" }}'
|
||||
value="clientsBulkModal.expiryTime" v-model="clientsBulkModal.expiryTime">
|
||||
<a-persian-datepicker v-else
|
||||
placeholder='{{ i18n "pages.settings.datepickerPlaceholder" }}'
|
||||
value="clientsBulkModal.expiryTime"
|
||||
v-model="clientsBulkModal.expiryTime">
|
||||
</a-persian-datepicker>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="clientsBulkModal.expiryTime != 0">
|
||||
@@ -120,7 +154,8 @@
|
||||
<a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input-number v-model.number="clientsBulkModal.reset" :min="0"></a-input-number>
|
||||
<a-input-number v-model.number="clientsBulkModal.reset"
|
||||
:min="0"></a-input-number>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
@@ -204,7 +239,7 @@
|
||||
this.security = "auto";
|
||||
this.flow = "";
|
||||
this.dbInbound = new DBInbound(dbInbound);
|
||||
this.inbound = dbInbound.toInbound();
|
||||
this.inbound = Inbound.fromJson(dbInbound.toInbound().toJson());
|
||||
this.delayedStart = false;
|
||||
this.reset = 0;
|
||||
},
|
||||
@@ -214,6 +249,7 @@
|
||||
case Protocols.VLESS: return new Inbound.VLESSSettings.VLESS();
|
||||
case Protocols.TROJAN: return new Inbound.TrojanSettings.Trojan();
|
||||
case Protocols.SHADOWSOCKS: return new Inbound.ShadowsocksSettings.Shadowsocks(clientsBulkModal.inbound.settings.shadowsockses[0].method);
|
||||
case Protocols.HYSTERIA: return new Inbound.HysteriaSettings.Hysteria();
|
||||
default: return null;
|
||||
}
|
||||
},
|
||||
@@ -247,4 +283,4 @@
|
||||
});
|
||||
|
||||
</script>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
{{define "modals/clientsModal"}}
|
||||
<a-modal id="client-modal" v-model="clientModal.visible" :title="clientModal.title" @ok="clientModal.ok"
|
||||
:confirm-loading="clientModal.confirmLoading" :closable="true" :mask-closable="false"
|
||||
:class="themeSwitcher.currentTheme"
|
||||
:ok-text="clientModal.okText" cancel-text='{{ i18n "close" }}'>
|
||||
<a-modal id="client-modal" v-model="clientModal.visible"
|
||||
:title="clientModal.title" @ok="clientModal.ok"
|
||||
:confirm-loading="clientModal.confirmLoading" :closable="true"
|
||||
:mask-closable="false"
|
||||
:class="themeSwitcher.currentTheme"
|
||||
:ok-text="clientModal.okText" cancel-text='{{ i18n "close" }}'>
|
||||
<template v-if="isEdit">
|
||||
<a-tag v-if="isExpiry || isTrafficExhausted" color="red" :style="{ marginBottom: '10px', display: 'block', textAlign: 'center' }">Account is (Expired|Traffic Ended) And Disabled</a-tag>
|
||||
<a-tag v-if="isExpiry || isTrafficExhausted" color="red"
|
||||
:style="{ marginBottom: '10px', display: 'block', textAlign: 'center' }">Account
|
||||
is (Expired|Traffic Ended) And Disabled</a-tag>
|
||||
</template>
|
||||
{{template "form/client"}}
|
||||
</a-modal>
|
||||
@@ -37,7 +41,7 @@
|
||||
this.okText = okText;
|
||||
this.isEdit = isEdit;
|
||||
this.dbInbound = new DBInbound(dbInbound);
|
||||
this.inbound = dbInbound.toInbound();
|
||||
this.inbound = Inbound.fromJson(dbInbound.toInbound().toJson());
|
||||
this.clients = this.inbound.clients;
|
||||
this.index = index === null ? this.clients.length : index;
|
||||
this.delayedStart = false;
|
||||
@@ -56,6 +60,7 @@
|
||||
switch (protocol) {
|
||||
case Protocols.TROJAN: return client.password;
|
||||
case Protocols.SHADOWSOCKS: return client.email;
|
||||
case Protocols.HYSTERIA: return client.auth;
|
||||
default: return client.id;
|
||||
}
|
||||
},
|
||||
@@ -65,6 +70,7 @@
|
||||
case Protocols.VLESS: return clients.push(new Inbound.VLESSSettings.VLESS());
|
||||
case Protocols.TROJAN: return clients.push(new Inbound.TrojanSettings.Trojan());
|
||||
case Protocols.SHADOWSOCKS: return clients.push(new Inbound.ShadowsocksSettings.Shadowsocks(clients[0].method, RandomUtil.randomShadowsocksPassword(inbound.settings.method)));
|
||||
case Protocols.HYSTERIA: return clients.push(new Inbound.HysteriaSettings.Hysteria());
|
||||
default: return null;
|
||||
}
|
||||
},
|
||||
@@ -98,9 +104,9 @@
|
||||
return app.datepicker;
|
||||
},
|
||||
get isTrafficExhausted() {
|
||||
if (!clientStats) return false
|
||||
if (clientStats.total <= 0) return false
|
||||
if (clientStats.up + clientStats.down < clientStats.total) return false
|
||||
if (!this.clientStats) return false
|
||||
if (this.clientStats.total <= 0) return false
|
||||
if (this.clientStats.up + this.clientStats.down < this.clientStats.total) return false
|
||||
return true
|
||||
},
|
||||
get isExpiry() {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
{{define "modals/inboundInfoModal"}}
|
||||
<a-modal id="inbound-info-modal" v-model="infoModal.visible"
|
||||
title='{{ i18n "pages.inbounds.details"}}' :closable="true"
|
||||
:mask-closable="true" :footer="null" width="600px"
|
||||
:class="themeSwitcher.currentTheme">
|
||||
<a-modal id="inbound-info-modal" v-model="infoModal.visible" title='{{ i18n "pages.inbounds.details"}}' :closable="true"
|
||||
:mask-closable="true" :footer="null" width="600px" :class="themeSwitcher.currentTheme">
|
||||
<a-row>
|
||||
<a-col :xs="24" :md="12">
|
||||
<table>
|
||||
@@ -29,8 +27,7 @@
|
||||
</table>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<template
|
||||
v-if="dbInbound.isVMess || dbInbound.isVLess || dbInbound.isTrojan || dbInbound.isSS">
|
||||
<template v-if="dbInbound.isVMess || dbInbound.isVLess || dbInbound.isTrojan || dbInbound.isSS">
|
||||
<table>
|
||||
<tr>
|
||||
<td>{{ i18n "transmission" }}</td>
|
||||
@@ -38,8 +35,7 @@
|
||||
<a-tag color="green">[[ inbound.network ]]</a-tag>
|
||||
</td>
|
||||
</tr>
|
||||
<template
|
||||
v-if="inbound.isTcp || inbound.isWs || inbound.isHttpupgrade || inbound.isXHTTP">
|
||||
<template v-if="inbound.isTcp || inbound.isWs || inbound.isHttpupgrade || inbound.isXHTTP">
|
||||
<tr>
|
||||
<td>{{ i18n "host" }}</td>
|
||||
<td v-if="inbound.host">
|
||||
@@ -51,13 +47,13 @@
|
||||
<a-tag color="orange">{{ i18n "none" }}</a-tag>
|
||||
</td>
|
||||
</tr>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ i18n "path" }}</td>
|
||||
<td v-if="inbound.path">
|
||||
<a-tooltip :title="[[ inbound.path ]]">
|
||||
<a-tag class="info-large-tag">[[ inbound.path ]]</a-tag>
|
||||
</a-tooltip>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ i18n "path" }}</td>
|
||||
<td v-if="inbound.path">
|
||||
<a-tooltip :title="[[ inbound.path ]]">
|
||||
<a-tag class="info-large-tag">[[ inbound.path ]]</a-tag>
|
||||
</a-tooltip>
|
||||
<td v-else>
|
||||
<a-tag color="orange">{{ i18n "none" }}</a-tag>
|
||||
</td>
|
||||
@@ -79,483 +75,452 @@
|
||||
<a-tag class="info-large-tag">[[ inbound.serviceName
|
||||
]]</a-tag>
|
||||
</a-tooltip>
|
||||
<tr>
|
||||
<td>grpc multiMode</td>
|
||||
<td>
|
||||
<a-tag>[[ inbound.stream.grpc.multiMode ]]</a-tag>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</table>
|
||||
<tr>
|
||||
<td>grpc multiMode</td>
|
||||
<td>
|
||||
<a-tag>[[ inbound.stream.grpc.multiMode ]]</a-tag>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</a-col>
|
||||
<template v-if="dbInbound.hasLink()">
|
||||
{{ i18n "security" }}
|
||||
<a-tag :color="inbound.stream.security == 'none' ? 'red' : 'green'">[[
|
||||
inbound.stream.security ]]</a-tag>
|
||||
<br />
|
||||
<td>Authentication</td>
|
||||
<a-tag v-if="inbound.settings.selectedAuth" color="green">[[
|
||||
inbound.settings.selectedAuth ? inbound.settings.selectedAuth : ''
|
||||
]]</a-tag>
|
||||
<a-tag v-else color="red">{{ i18n "none" }}</a-tag>
|
||||
<br />
|
||||
{{ i18n "encryption" }}
|
||||
<a-tag class="info-large-tag"
|
||||
:color="inbound.settings.encryption ? 'green' : 'red'">[[
|
||||
inbound.settings.encryption ? inbound.settings.encryption : ''
|
||||
]]</a-tag>
|
||||
<a-tooltip title='{{ i18n "copy" }}'>
|
||||
<a-button size="small" icon="snippets"
|
||||
@click="copy(inbound.settings.encryption)"></a-button>
|
||||
</a-tooltip>
|
||||
<br />
|
||||
<template v-if="inbound.stream.security != 'none'">
|
||||
{{ i18n "domainName" }}
|
||||
<a-tag v-if="inbound.serverName" color="green">[[ inbound.serverName
|
||||
? inbound.serverName : '' ]]</a-tag>
|
||||
<a-tag v-else color="orange">{{ i18n "none" }}</a-tag>
|
||||
</template>
|
||||
</template>
|
||||
<table v-if="dbInbound.isSS"
|
||||
:style="{ marginBottom: '10px', width: '100%' }">
|
||||
<tr>
|
||||
<td>{{ i18n "encryption" }}</td>
|
||||
<td>
|
||||
<a-tag color="green">[[ inbound.settings.method ]]</a-tag>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="inbound.isSS2022">
|
||||
<td>{{ i18n "password" }}</td>
|
||||
<td>
|
||||
<a-tooltip :title="[[ inbound.settings.password ]]">
|
||||
<a-tag class="info-large-tag">[[ inbound.settings.password
|
||||
]]</a-tag>
|
||||
</a-tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ i18n "pages.inbounds.network" }}</td>
|
||||
<td>
|
||||
<a-tag color="green">[[ inbound.settings.network ]]</a-tag>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<template v-if="infoModal.clientSettings">
|
||||
<a-divider>{{ i18n "pages.inbounds.client" }}</a-divider>
|
||||
<table :style="{ marginBottom: '10px' }">
|
||||
<tr>
|
||||
<td>{{ i18n "pages.inbounds.email" }}</td>
|
||||
<td v-if="infoModal.clientSettings.email">
|
||||
<a-tag color="green">[[ infoModal.clientSettings.email
|
||||
]]</a-tag>
|
||||
</td>
|
||||
<td v-else>
|
||||
<a-tag color="red">{{ i18n "none" }}</a-tag>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="infoModal.clientSettings.id">
|
||||
<td>ID</td>
|
||||
<td>
|
||||
<a-tag>[[ infoModal.clientSettings.id ]]</a-tag>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="dbInbound.isVMess">
|
||||
<td>{{ i18n "security" }}</td>
|
||||
<td>
|
||||
<a-tag>[[ infoModal.clientSettings.security ]]</a-tag>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="infoModal.inbound.canEnableTlsFlow()">
|
||||
<td>Flow</td>
|
||||
<td v-if="infoModal.clientSettings.flow">
|
||||
<a-tag>[[ infoModal.clientSettings.flow ]]</a-tag>
|
||||
</td>
|
||||
<td v-else>
|
||||
<a-tag color="orange">{{ i18n "none" }}</a-tag>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="infoModal.clientSettings.password">
|
||||
<td>{{ i18n "password" }}</td>
|
||||
<td>
|
||||
<a-tooltip :title="[[ infoModal.clientSettings.password ]]">
|
||||
<a-tag class="info-large-tag">[[
|
||||
infoModal.clientSettings.password ]]</a-tag>
|
||||
</a-tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ i18n "status" }}</td>
|
||||
<td>
|
||||
<a-tag v-if="isDepleted" color="red">{{ i18n "depleted"
|
||||
}}</a-tag>
|
||||
<a-tag v-else-if="isEnable" color="green">{{ i18n "enabled"
|
||||
}}</a-tag>
|
||||
<a-tag v-else>{{ i18n "disabled" }}</a-tag>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="infoModal.clientStats">
|
||||
<td>{{ i18n "usage" }}</td>
|
||||
<td>
|
||||
<a-tag color="green">[[
|
||||
SizeFormatter.sizeFormat(infoModal.clientStats.up +
|
||||
infoModal.clientStats.down) ]]</a-tag>
|
||||
<a-tag>↑ [[ SizeFormatter.sizeFormat(infoModal.clientStats.up)
|
||||
]] / [[ SizeFormatter.sizeFormat(infoModal.clientStats.down)
|
||||
]] ↓</a-tag>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ i18n "pages.inbounds.createdAt" }}</td>
|
||||
<td>
|
||||
<template
|
||||
v-if="infoModal.clientSettings && infoModal.clientSettings.created_at">
|
||||
<a-tag>[[
|
||||
IntlUtil.formatDate(infoModal.clientSettings.created_at)
|
||||
]]</a-tag>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-tag>-</a-tag>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ i18n "pages.inbounds.updatedAt" }}</td>
|
||||
<td>
|
||||
<template
|
||||
v-if="infoModal.clientSettings && infoModal.clientSettings.updated_at">
|
||||
<a-tag>[[
|
||||
IntlUtil.formatDate(infoModal.clientSettings.updated_at)
|
||||
]]</a-tag>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-tag>-</a-tag>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ i18n "lastOnline" }}</td>
|
||||
<td>
|
||||
<a-tag>[[ app.formatLastOnline(infoModal.clientSettings &&
|
||||
infoModal.clientSettings.email ?
|
||||
infoModal.clientSettings.email : '') ]]</a-tag>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="infoModal.clientSettings.comment">
|
||||
<td>{{ i18n "comment" }}</td>
|
||||
<td>
|
||||
<a-tooltip :title="[[ infoModal.clientSettings.comment ]]">
|
||||
<a-tag class="info-large-tag">[[
|
||||
infoModal.clientSettings.comment ]]</a-tag>
|
||||
</a-tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="app.ipLimitEnable">
|
||||
<td>{{ i18n "pages.inbounds.IPLimit" }}</td>
|
||||
<td>
|
||||
<a-tag>[[ infoModal.clientSettings.limitIp ]]</a-tag>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-if="app.ipLimitEnable && infoModal.clientSettings.limitIp > 0">
|
||||
<td>{{ i18n "pages.inbounds.IPLimitlog" }}</td>
|
||||
<td>
|
||||
<div
|
||||
style="max-height: 150px; overflow-y: auto; text-align: left;">
|
||||
<div
|
||||
v-if="infoModal.clientIpsArray && infoModal.clientIpsArray.length > 0">
|
||||
<a-tag
|
||||
v-for="(ipInfo, idx) in infoModal.clientIpsArray"
|
||||
:key="idx"
|
||||
color="blue"
|
||||
style="margin: 2px 0; display: block; font-family: monospace; font-size: 11px;">
|
||||
[[ formatIpInfo(ipInfo) ]]
|
||||
</a-tag>
|
||||
</div>
|
||||
<a-tag v-else>[[ infoModal.clientIps || 'No IP Record'
|
||||
]]</a-tag>
|
||||
</div>
|
||||
<div style="margin-top: 5px;">
|
||||
<a-icon type="sync" :spin="refreshing" @click="refreshIPs"
|
||||
:style="{ margin: '0 5px' }"></a-icon>
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<span>{{ i18n "pages.inbounds.IPLimitlogclear" }}</span>
|
||||
</template>
|
||||
<a-icon type="delete" @click="clearClientIps"></a-icon>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table
|
||||
:style="{ display: 'inline-table', marginBlock: '10px', width: '100%', textAlign: 'center' }">
|
||||
<tr>
|
||||
<th>{{ i18n "remained" }}</th>
|
||||
<th>{{ i18n "pages.inbounds.totalFlow" }}</th>
|
||||
<th>{{ i18n "pages.inbounds.expireDate" }}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a-tag
|
||||
v-if="infoModal.clientStats && infoModal.clientSettings.totalGB > 0"
|
||||
:color="statsColor(infoModal.clientStats)"> [[ getRemStats()
|
||||
]] </a-tag>
|
||||
</td>
|
||||
<td>
|
||||
<a-tag v-if="infoModal.clientSettings.totalGB > 0"
|
||||
:color="statsColor(infoModal.clientStats)"> [[
|
||||
SizeFormatter.sizeFormat(infoModal.clientSettings.totalGB) ]]
|
||||
</a-tag>
|
||||
<a-tag v-else color="purple" class="infinite-tag">
|
||||
<svg height="10px" width="14px" viewBox="0 0 640 512"
|
||||
fill="currentColor">
|
||||
<path
|
||||
d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z"
|
||||
fill="currentColor"></path>
|
||||
</svg>
|
||||
</a-tag>
|
||||
</td>
|
||||
<td>
|
||||
<template v-if="infoModal.clientSettings.expiryTime > 0">
|
||||
<a-tag
|
||||
:color="ColorUtils.usageColor(new Date().getTime(), app.expireDiff, infoModal.clientSettings.expiryTime)">
|
||||
[[ IntlUtil.formatDate(infoModal.clientSettings.expiryTime)
|
||||
]]
|
||||
</a-tag>
|
||||
</template>
|
||||
<a-tag v-else-if="infoModal.clientSettings.expiryTime < 0"
|
||||
color="green">[[ infoModal.clientSettings.expiryTime /
|
||||
-86400000 ]] {{ i18n "pages.client.days" }}
|
||||
</a-tag>
|
||||
<a-tag v-else color="purple" class="infinite-tag">
|
||||
<svg height="10px" width="14px" viewBox="0 0 640 512"
|
||||
fill="currentColor">
|
||||
<path
|
||||
d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z"
|
||||
fill="currentColor"></path>
|
||||
</svg>
|
||||
</a-tag>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<template
|
||||
v-if="app.subSettings.enable && infoModal.clientSettings.subId">
|
||||
<a-divider>Subscription URL</a-divider>
|
||||
<tr-info-row class="tr-info-row">
|
||||
<tr-info-title class="tr-info-title">
|
||||
<a-tag color="purple">Subscription Link</a-tag>
|
||||
<a-tooltip title='{{ i18n "copy" }}'>
|
||||
<a-button size="small" icon="snippets"
|
||||
@click="copy(infoModal.subLink)"></a-button>
|
||||
</a-tooltip>
|
||||
</tr-info-title>
|
||||
<a :href="[[ infoModal.subLink ]]" target="_blank">[[
|
||||
infoModal.subLink ]]</a>
|
||||
</tr-info-row>
|
||||
<tr-info-row class="tr-info-row"
|
||||
v-if="app.subSettings.subJsonEnable">
|
||||
<tr-info-title class="tr-info-title">
|
||||
<a-tag color="purple">Json Link</a-tag>
|
||||
<a-tooltip title='{{ i18n "copy" }}'>
|
||||
<a-button size="small" icon="snippets"
|
||||
@click="copy(infoModal.subJsonLink)"></a-button>
|
||||
</a-tooltip>
|
||||
</tr-info-title>
|
||||
<a :href="[[ infoModal.subJsonLink ]]" target="_blank">[[
|
||||
infoModal.subJsonLink ]]</a>
|
||||
</tr-info-row>
|
||||
</template>
|
||||
<template v-if="app.tgBotEnable && infoModal.clientSettings.tgId">
|
||||
<a-divider>Telegram ChatID</a-divider>
|
||||
<tr-info-row class="tr-info-row">
|
||||
<tr-info-title class="tr-info-title">
|
||||
<a-tag color="blue">[[ infoModal.clientSettings.tgId ]]</a-tag>
|
||||
<a-tooltip title='{{ i18n "copy" }}'>
|
||||
<a-button size="small" icon="snippets"
|
||||
@click="copy(infoModal.clientSettings.tgId)"></a-button>
|
||||
</a-tooltip>
|
||||
</tr-info-title>
|
||||
</tr-info-row>
|
||||
</template>
|
||||
<template v-if="dbInbound.hasLink()">
|
||||
<a-divider>URL</a-divider>
|
||||
<tr-info-row v-for="(link,index) in infoModal.links"
|
||||
class="tr-info-row">
|
||||
<tr-info-title class="tr-info-title">
|
||||
<a-tag class="tr-info-tag" color="green">[[ link.remark
|
||||
]]</a-tag>
|
||||
<a-tooltip title='{{ i18n "copy" }}'>
|
||||
<a-button :style="{ minWidth: '24px' }" size="small"
|
||||
icon="snippets" @click="copy(link.link)"></a-button>
|
||||
</a-tooltip>
|
||||
</tr-info-title>
|
||||
<code>[[ link.link ]]</code>
|
||||
</tr-info-row>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-if="dbInbound.isSS && !inbound.isSSMultiUser">
|
||||
<a-divider>URL</a-divider>
|
||||
<tr-info-row v-for="(link,index) in infoModal.links"
|
||||
class="tr-info-row">
|
||||
<tr-info-title class="tr-info-title">
|
||||
<a-tag class="tr-info-tag" color="green">[[ link.remark
|
||||
]]</a-tag>
|
||||
<a-tooltip title='{{ i18n "copy" }}'>
|
||||
<a-button :style="{ minWidth: '24px' }" size="small"
|
||||
icon="snippets" @click="copy(link.link)"></a-button>
|
||||
</a-tooltip>
|
||||
</tr-info-title>
|
||||
<code>[[ link.link ]]</code>
|
||||
</tr-info-row>
|
||||
</template>
|
||||
<table v-if="inbound.protocol == Protocols.TUNNEL"
|
||||
class="tr-info-table">
|
||||
<tr>
|
||||
<th>{{ i18n "pages.inbounds.targetAddress" }}</th>
|
||||
<th>{{ i18n "pages.inbounds.destinationPort" }}</th>
|
||||
<th>{{ i18n "pages.inbounds.network" }}</th>
|
||||
<th>FollowRedirect</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a-tag color="green">[[ inbound.settings.address ]]</a-tag>
|
||||
</td>
|
||||
<td>
|
||||
<a-tag color="green">[[ inbound.settings.port ]]</a-tag>
|
||||
</td>
|
||||
<td>
|
||||
<a-tag color="green">[[ inbound.settings.network ]]</a-tag>
|
||||
</td>
|
||||
<td>
|
||||
<a-tag color="green">[[ inbound.settings.followRedirect
|
||||
]]</a-tag>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table v-if="dbInbound.isMixed" class="tr-info-table">
|
||||
<tr>
|
||||
<th>{{ i18n "password" }} Auth</th>
|
||||
<th>{{ i18n "pages.inbounds.enable" }} udp</th>
|
||||
<th>IP</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a-tag color="green">[[ inbound.settings.auth ]]</a-tag>
|
||||
</td>
|
||||
<td>
|
||||
<a-tag color="green">[[ inbound.settings.udp]]</a-tag>
|
||||
</td>
|
||||
<td>
|
||||
<a-tag color="green">[[ inbound.settings.ip ]]</a-tag>
|
||||
</td>
|
||||
</tr>
|
||||
<template v-if="inbound.settings.auth == 'password'">
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>{{ i18n "username" }}</td>
|
||||
<td>{{ i18n "password" }}</td>
|
||||
</tr>
|
||||
<tr v-for="account,index in inbound.settings.accounts">
|
||||
<td>[[ index ]]</td>
|
||||
<td>
|
||||
<a-tag color="green">[[ account.user ]]</a-tag>
|
||||
</td>
|
||||
<td>
|
||||
<a-tag color="green">[[ account.pass ]]</a-tag>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</a-col>
|
||||
<template v-if="dbInbound.hasLink()">
|
||||
{{ i18n "security" }}
|
||||
<a-tag :color="inbound.stream.security == 'none' ? 'red' : 'green'">[[
|
||||
inbound.stream.security ]]</a-tag>
|
||||
<br />
|
||||
<td>Authentication</td>
|
||||
<a-tag v-if="inbound.settings.selectedAuth" color="green">[[
|
||||
inbound.settings.selectedAuth ? inbound.settings.selectedAuth : ''
|
||||
]]</a-tag>
|
||||
<a-tag v-else color="red">{{ i18n "none" }}</a-tag>
|
||||
<br />
|
||||
{{ i18n "encryption" }}
|
||||
<a-tag class="info-large-tag" :color="inbound.settings.encryption ? 'green' : 'red'">[[
|
||||
inbound.settings.encryption ? inbound.settings.encryption : ''
|
||||
]]</a-tag>
|
||||
<a-tooltip title='{{ i18n "copy" }}'>
|
||||
<a-button size="small" icon="snippets" @click="copy(inbound.settings.encryption)"></a-button>
|
||||
</a-tooltip>
|
||||
<br />
|
||||
<template v-if="inbound.stream.security != 'none'">
|
||||
{{ i18n "domainName" }}
|
||||
<a-tag v-if="inbound.serverName" color="green">[[ inbound.serverName
|
||||
? inbound.serverName : '' ]]</a-tag>
|
||||
<a-tag v-else color="orange">{{ i18n "none" }}</a-tag>
|
||||
</template>
|
||||
</template>
|
||||
<table v-if="dbInbound.isSS" :style="{ marginBottom: '10px', width: '100%' }">
|
||||
<tr>
|
||||
<td>{{ i18n "encryption" }}</td>
|
||||
<td>
|
||||
<a-tag color="green">[[ inbound.settings.method ]]</a-tag>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="inbound.isSS2022">
|
||||
<td>{{ i18n "password" }}</td>
|
||||
<td>
|
||||
<a-tooltip :title="[[ inbound.settings.password ]]">
|
||||
<a-tag class="info-large-tag">[[ inbound.settings.password
|
||||
]]</a-tag>
|
||||
</a-tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ i18n "pages.inbounds.network" }}</td>
|
||||
<td>
|
||||
<a-tag color="green">[[ inbound.settings.network ]]</a-tag>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<template v-if="infoModal.clientSettings">
|
||||
<a-divider>{{ i18n "pages.inbounds.client" }}</a-divider>
|
||||
<table :style="{ marginBottom: '10px' }">
|
||||
<tr>
|
||||
<td>{{ i18n "pages.inbounds.email" }}</td>
|
||||
<td v-if="infoModal.clientSettings.email">
|
||||
<a-tag color="green">[[ infoModal.clientSettings.email
|
||||
]]</a-tag>
|
||||
</td>
|
||||
<td v-else>
|
||||
<a-tag color="red">{{ i18n "none" }}</a-tag>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="infoModal.clientSettings.id">
|
||||
<td>ID</td>
|
||||
<td>
|
||||
<a-tag>[[ infoModal.clientSettings.id ]]</a-tag>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="dbInbound.isVMess">
|
||||
<td>{{ i18n "security" }}</td>
|
||||
<td>
|
||||
<a-tag>[[ infoModal.clientSettings.security ]]</a-tag>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="infoModal.inbound.canEnableTlsFlow()">
|
||||
<td>Flow</td>
|
||||
<td v-if="infoModal.clientSettings.flow">
|
||||
<a-tag>[[ infoModal.clientSettings.flow ]]</a-tag>
|
||||
</td>
|
||||
<td v-else>
|
||||
<a-tag color="orange">{{ i18n "none" }}</a-tag>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="infoModal.clientSettings.password">
|
||||
<td>{{ i18n "password" }}</td>
|
||||
<td>
|
||||
<a-tooltip :title="[[ infoModal.clientSettings.password ]]">
|
||||
<a-tag class="info-large-tag">[[
|
||||
infoModal.clientSettings.password ]]</a-tag>
|
||||
</a-tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ i18n "status" }}</td>
|
||||
<td>
|
||||
<a-tag v-if="isDepleted" color="red">{{ i18n "depleted"
|
||||
}}</a-tag>
|
||||
<a-tag v-else-if="isEnable" color="green">{{ i18n "enabled"
|
||||
}}</a-tag>
|
||||
<a-tag v-else>{{ i18n "disabled" }}</a-tag>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="infoModal.clientStats">
|
||||
<td>{{ i18n "usage" }}</td>
|
||||
<td>
|
||||
<a-tag color="green">[[
|
||||
SizeFormatter.sizeFormat(infoModal.clientStats.up +
|
||||
infoModal.clientStats.down) ]]</a-tag>
|
||||
<a-tag>↑ [[ SizeFormatter.sizeFormat(infoModal.clientStats.up)
|
||||
]] / [[ SizeFormatter.sizeFormat(infoModal.clientStats.down)
|
||||
]] ↓</a-tag>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ i18n "pages.inbounds.createdAt" }}</td>
|
||||
<td>
|
||||
<template v-if="infoModal.clientSettings && infoModal.clientSettings.created_at">
|
||||
<a-tag>[[
|
||||
IntlUtil.formatDate(infoModal.clientSettings.created_at)
|
||||
]]</a-tag>
|
||||
</template>
|
||||
</table>
|
||||
<table v-if="dbInbound.isHTTP" class="tr-info-table">
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>{{ i18n "username" }}</th>
|
||||
<th>{{ i18n "password" }}</th>
|
||||
</tr>
|
||||
<tr v-for="account,index in inbound.settings.accounts">
|
||||
<td>[[ index ]]</td>
|
||||
<td>
|
||||
<a-tag color="green">[[ account.user ]]</a-tag>
|
||||
</td>
|
||||
<td>
|
||||
<a-tag color="green">[[ account.pass ]]</a-tag>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table v-if="dbInbound.isWireguard" class="tr-info-table">
|
||||
<tr class="client-table-odd-row">
|
||||
<td>{{ i18n "pages.xray.wireguard.secretKey" }}</td>
|
||||
<td>[[ inbound.settings.secretKey ]]</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ i18n "pages.xray.wireguard.publicKey" }}</td>
|
||||
<td>[[ inbound.settings.pubKey ]]</td>
|
||||
</tr>
|
||||
<tr class="client-table-odd-row">
|
||||
<td>MTU</td>
|
||||
<td>[[ inbound.settings.mtu ]]</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>No Kernel Tun</td>
|
||||
<td>[[ inbound.settings.noKernelTun ]]</td>
|
||||
</tr>
|
||||
<template v-for="(peer, index) in inbound.settings.peers">
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
<a-divider>Peer [[ index + 1 ]]</a-divider>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="client-table-odd-row">
|
||||
<td>{{ i18n "pages.xray.wireguard.secretKey" }}</td>
|
||||
<td>[[ peer.privateKey ]]</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ i18n "pages.xray.wireguard.publicKey" }}</td>
|
||||
<td>[[ peer.publicKey ]]</td>
|
||||
</tr>
|
||||
<tr class="client-table-odd-row">
|
||||
<td>{{ i18n "pages.xray.wireguard.psk" }}</td>
|
||||
<td>[[ peer.psk ]]</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ i18n "pages.xray.wireguard.allowedIPs" }}</td>
|
||||
<td>[[ peer.allowedIPs.join(",") ]]</td>
|
||||
</tr>
|
||||
<tr class="client-table-odd-row">
|
||||
<td>Keep Alive</td>
|
||||
<td>[[ peer.keepAlive ]]</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
<tr-info-row class="tr-info-row">
|
||||
<tr-info-title class="tr-info-title">
|
||||
<a-tag color="blue">Config</a-tag>
|
||||
<a-tooltip title='{{ i18n "copy" }}'>
|
||||
<a-button :style="{ minWidth: '24px' }" size="small"
|
||||
icon="snippets"
|
||||
@click="copy(infoModal.links[index])"></a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip title='{{ i18n "download" }}'>
|
||||
<a-button :style="{ minWidth: '24px' }" size="small"
|
||||
icon="download"
|
||||
@click="FileManager.downloadTextFile(infoModal.links[index], `peer-${index + 1}.conf`)"></a-button>
|
||||
</a-tooltip>
|
||||
</tr-info-title>
|
||||
<div
|
||||
v-html="infoModal.links[index].replaceAll(`\n`,`<br />`)"
|
||||
:style="{ borderRadius: '1rem', padding: '0.5rem' }"
|
||||
class="client-table-odd-row">
|
||||
</div>
|
||||
</tr-info-row>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-tag>-</a-tag>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ i18n "pages.inbounds.updatedAt" }}</td>
|
||||
<td>
|
||||
<template v-if="infoModal.clientSettings && infoModal.clientSettings.updated_at">
|
||||
<a-tag>[[
|
||||
IntlUtil.formatDate(infoModal.clientSettings.updated_at)
|
||||
]]</a-tag>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-tag>-</a-tag>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ i18n "lastOnline" }}</td>
|
||||
<td>
|
||||
<a-tag>[[ app.formatLastOnline(infoModal.clientSettings &&
|
||||
infoModal.clientSettings.email ?
|
||||
infoModal.clientSettings.email : '') ]]</a-tag>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="infoModal.clientSettings.comment">
|
||||
<td>{{ i18n "comment" }}</td>
|
||||
<td>
|
||||
<a-tooltip :title="[[ infoModal.clientSettings.comment ]]">
|
||||
<a-tag class="info-large-tag">[[
|
||||
infoModal.clientSettings.comment ]]</a-tag>
|
||||
</a-tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="app.ipLimitEnable">
|
||||
<td>{{ i18n "pages.inbounds.IPLimit" }}</td>
|
||||
<td>
|
||||
<a-tag>[[ infoModal.clientSettings.limitIp ]]</a-tag>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="app.ipLimitEnable && infoModal.clientSettings.limitIp > 0">
|
||||
<td>{{ i18n "pages.inbounds.IPLimitlog" }}</td>
|
||||
<td>
|
||||
<div style="max-height: 150px; overflow-y: auto; text-align: left;">
|
||||
<div v-if="infoModal.clientIpsArray && infoModal.clientIpsArray.length > 0">
|
||||
<a-tag v-for="(ipInfo, idx) in infoModal.clientIpsArray" :key="idx" color="blue"
|
||||
style="margin: 2px 0; display: block; font-family: monospace; font-size: 11px;">
|
||||
[[ formatIpInfo(ipInfo) ]]
|
||||
</a-tag>
|
||||
</div>
|
||||
<a-tag v-else>[[ infoModal.clientIps || 'No IP Record'
|
||||
]]</a-tag>
|
||||
</div>
|
||||
<div style="margin-top: 5px;">
|
||||
<a-icon type="sync" :spin="refreshing" @click="refreshIPs" :style="{ margin: '0 5px' }"></a-icon>
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<span>{{ i18n "pages.inbounds.IPLimitlogclear" }}</span>
|
||||
</template>
|
||||
<a-icon type="delete" @click="clearClientIps"></a-icon>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table :style="{ display: 'inline-table', marginBlock: '10px', width: '100%', textAlign: 'center' }">
|
||||
<tr>
|
||||
<th>{{ i18n "remained" }}</th>
|
||||
<th>{{ i18n "pages.inbounds.totalFlow" }}</th>
|
||||
<th>{{ i18n "pages.inbounds.expireDate" }}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a-tag v-if="infoModal.clientStats && infoModal.clientSettings.totalGB > 0"
|
||||
:color="statsColor(infoModal.clientStats)"> [[ getRemStats()
|
||||
]] </a-tag>
|
||||
</td>
|
||||
<td>
|
||||
<a-tag v-if="infoModal.clientSettings.totalGB > 0" :color="statsColor(infoModal.clientStats)"> [[
|
||||
SizeFormatter.sizeFormat(infoModal.clientSettings.totalGB) ]]
|
||||
</a-tag>
|
||||
<a-tag v-else color="purple" class="infinite-tag">
|
||||
<svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor">
|
||||
<path
|
||||
d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z"
|
||||
fill="currentColor"></path>
|
||||
</svg>
|
||||
</a-tag>
|
||||
</td>
|
||||
<td>
|
||||
<template v-if="infoModal.clientSettings.expiryTime > 0">
|
||||
<a-tag
|
||||
:color="ColorUtils.usageColor(new Date().getTime(), app.expireDiff, infoModal.clientSettings.expiryTime)">
|
||||
[[ IntlUtil.formatDate(infoModal.clientSettings.expiryTime)
|
||||
]]
|
||||
</a-tag>
|
||||
</template>
|
||||
<a-tag v-else-if="infoModal.clientSettings.expiryTime < 0" color="green">[[
|
||||
infoModal.clientSettings.expiryTime /
|
||||
-86400000 ]] {{ i18n "pages.client.days" }}
|
||||
</a-tag>
|
||||
<a-tag v-else color="purple" class="infinite-tag">
|
||||
<svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor">
|
||||
<path
|
||||
d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z"
|
||||
fill="currentColor"></path>
|
||||
</svg>
|
||||
</a-tag>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<template v-if="app.subSettings.enable && infoModal.clientSettings.subId">
|
||||
<a-divider>Subscription URL</a-divider>
|
||||
<tr-info-row class="tr-info-row">
|
||||
<tr-info-title class="tr-info-title">
|
||||
<a-tag color="purple">Subscription Link</a-tag>
|
||||
<a-tooltip title='{{ i18n "copy" }}'>
|
||||
<a-button size="small" icon="snippets" @click="copy(infoModal.subLink)"></a-button>
|
||||
</a-tooltip>
|
||||
</tr-info-title>
|
||||
<a :href="[[ infoModal.subLink ]]" target="_blank">[[
|
||||
infoModal.subLink ]]</a>
|
||||
</tr-info-row>
|
||||
<tr-info-row class="tr-info-row" v-if="app.subSettings.subJsonEnable">
|
||||
<tr-info-title class="tr-info-title">
|
||||
<a-tag color="purple">Json Link</a-tag>
|
||||
<a-tooltip title='{{ i18n "copy" }}'>
|
||||
<a-button size="small" icon="snippets" @click="copy(infoModal.subJsonLink)"></a-button>
|
||||
</a-tooltip>
|
||||
</tr-info-title>
|
||||
<a :href="[[ infoModal.subJsonLink ]]" target="_blank">[[
|
||||
infoModal.subJsonLink ]]</a>
|
||||
</tr-info-row>
|
||||
</template>
|
||||
<template v-if="app.tgBotEnable && infoModal.clientSettings.tgId">
|
||||
<a-divider>Telegram ChatID</a-divider>
|
||||
<tr-info-row class="tr-info-row">
|
||||
<tr-info-title class="tr-info-title">
|
||||
<a-tag color="blue">[[ infoModal.clientSettings.tgId ]]</a-tag>
|
||||
<a-tooltip title='{{ i18n "copy" }}'>
|
||||
<a-button size="small" icon="snippets" @click="copy(infoModal.clientSettings.tgId)"></a-button>
|
||||
</a-tooltip>
|
||||
</tr-info-title>
|
||||
</tr-info-row>
|
||||
</template>
|
||||
<template v-if="dbInbound.hasLink()">
|
||||
<a-divider>URL</a-divider>
|
||||
<tr-info-row v-for="(link,index) in infoModal.links" class="tr-info-row">
|
||||
<tr-info-title class="tr-info-title">
|
||||
<a-tag class="tr-info-tag" color="green">[[ link.remark
|
||||
]]</a-tag>
|
||||
<a-tooltip title='{{ i18n "copy" }}'>
|
||||
<a-button :style="{ minWidth: '24px' }" size="small" icon="snippets" @click="copy(link.link)"></a-button>
|
||||
</a-tooltip>
|
||||
</tr-info-title>
|
||||
<code>[[ link.link ]]</code>
|
||||
</tr-info-row>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-if="dbInbound.isSS && !inbound.isSSMultiUser">
|
||||
<a-divider>URL</a-divider>
|
||||
<tr-info-row v-for="(link,index) in infoModal.links" class="tr-info-row">
|
||||
<tr-info-title class="tr-info-title">
|
||||
<a-tag class="tr-info-tag" color="green">[[ link.remark
|
||||
]]</a-tag>
|
||||
<a-tooltip title='{{ i18n "copy" }}'>
|
||||
<a-button :style="{ minWidth: '24px' }" size="small" icon="snippets" @click="copy(link.link)"></a-button>
|
||||
</a-tooltip>
|
||||
</tr-info-title>
|
||||
<code>[[ link.link ]]</code>
|
||||
</tr-info-row>
|
||||
</template>
|
||||
<table v-if="inbound.protocol == Protocols.TUNNEL" class="tr-info-table">
|
||||
<tr>
|
||||
<th>{{ i18n "pages.inbounds.targetAddress" }}</th>
|
||||
<th>{{ i18n "pages.inbounds.destinationPort" }}</th>
|
||||
<th>{{ i18n "pages.inbounds.network" }}</th>
|
||||
<th>FollowRedirect</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a-tag color="green">[[ inbound.settings.address ]]</a-tag>
|
||||
</td>
|
||||
<td>
|
||||
<a-tag color="green">[[ inbound.settings.port ]]</a-tag>
|
||||
</td>
|
||||
<td>
|
||||
<a-tag color="green">[[ inbound.settings.network ]]</a-tag>
|
||||
</td>
|
||||
<td>
|
||||
<a-tag color="green">[[ inbound.settings.followRedirect
|
||||
]]</a-tag>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table v-if="dbInbound.isMixed" class="tr-info-table">
|
||||
<tr>
|
||||
<th>{{ i18n "password" }} Auth</th>
|
||||
<th>{{ i18n "pages.inbounds.enable" }} udp</th>
|
||||
<th>IP</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a-tag color="green">[[ inbound.settings.auth ]]</a-tag>
|
||||
</td>
|
||||
<td>
|
||||
<a-tag color="green">[[ inbound.settings.udp]]</a-tag>
|
||||
</td>
|
||||
<td>
|
||||
<a-tag color="green">[[ inbound.settings.ip ]]</a-tag>
|
||||
</td>
|
||||
</tr>
|
||||
<template v-if="inbound.settings.auth == 'password'">
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>{{ i18n "username" }}</td>
|
||||
<td>{{ i18n "password" }}</td>
|
||||
</tr>
|
||||
<tr v-for="account,index in inbound.settings.accounts">
|
||||
<td>[[ index ]]</td>
|
||||
<td>
|
||||
<a-tag color="green">[[ account.user ]]</a-tag>
|
||||
</td>
|
||||
<td>
|
||||
<a-tag color="green">[[ account.pass ]]</a-tag>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</a-modal>
|
||||
<script>
|
||||
</table>
|
||||
<table v-if="dbInbound.isHTTP" class="tr-info-table">
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>{{ i18n "username" }}</th>
|
||||
<th>{{ i18n "password" }}</th>
|
||||
</tr>
|
||||
<tr v-for="account,index in inbound.settings.accounts">
|
||||
<td>[[ index ]]</td>
|
||||
<td>
|
||||
<a-tag color="green">[[ account.user ]]</a-tag>
|
||||
</td>
|
||||
<td>
|
||||
<a-tag color="green">[[ account.pass ]]</a-tag>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table v-if="dbInbound.isWireguard" class="tr-info-table">
|
||||
<tr class="client-table-odd-row">
|
||||
<td>{{ i18n "pages.xray.wireguard.secretKey" }}</td>
|
||||
<td>[[ inbound.settings.secretKey ]]</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ i18n "pages.xray.wireguard.publicKey" }}</td>
|
||||
<td>[[ inbound.settings.pubKey ]]</td>
|
||||
</tr>
|
||||
<tr class="client-table-odd-row">
|
||||
<td>MTU</td>
|
||||
<td>[[ inbound.settings.mtu ]]</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>No Kernel Tun</td>
|
||||
<td>[[ inbound.settings.noKernelTun ]]</td>
|
||||
</tr>
|
||||
<template v-for="(peer, index) in inbound.settings.peers">
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
<a-divider>Peer [[ index + 1 ]]</a-divider>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="client-table-odd-row">
|
||||
<td>{{ i18n "pages.xray.wireguard.secretKey" }}</td>
|
||||
<td>[[ peer.privateKey ]]</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ i18n "pages.xray.wireguard.publicKey" }}</td>
|
||||
<td>[[ peer.publicKey ]]</td>
|
||||
</tr>
|
||||
<tr class="client-table-odd-row">
|
||||
<td>{{ i18n "pages.xray.wireguard.psk" }}</td>
|
||||
<td>[[ peer.psk ]]</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ i18n "pages.xray.wireguard.allowedIPs" }}</td>
|
||||
<td>[[ peer.allowedIPs.join(",") ]]</td>
|
||||
</tr>
|
||||
<tr class="client-table-odd-row">
|
||||
<td>Keep Alive</td>
|
||||
<td>[[ peer.keepAlive ]]</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
<tr-info-row class="tr-info-row">
|
||||
<tr-info-title class="tr-info-title">
|
||||
<a-tag color="blue">Config</a-tag>
|
||||
<a-tooltip title='{{ i18n "copy" }}'>
|
||||
<a-button :style="{ minWidth: '24px' }" size="small" icon="snippets"
|
||||
@click="copy(infoModal.links[index])"></a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip title='{{ i18n "download" }}'>
|
||||
<a-button :style="{ minWidth: '24px' }" size="small" icon="download"
|
||||
@click="FileManager.downloadTextFile(infoModal.links[index], `peer-${index + 1}.conf`)"></a-button>
|
||||
</a-tooltip>
|
||||
</tr-info-title>
|
||||
<div v-html="infoModal.links[index].replaceAll(`\n`,`<br />`)"
|
||||
:style="{ borderRadius: '1rem', padding: '0.5rem' }" class="client-table-odd-row">
|
||||
</div>
|
||||
</tr-info-row>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</template>
|
||||
</template>
|
||||
</a-modal>
|
||||
<script>
|
||||
function refreshIPs(email) {
|
||||
return HttpUtil.post(`/panel/api/inbounds/clientIps/${email}`).then((msg) => {
|
||||
if (!msg.success) {
|
||||
@@ -654,9 +619,9 @@
|
||||
|
||||
if (
|
||||
[
|
||||
Protocols.VMESS,
|
||||
Protocols.VMESS,
|
||||
Protocols.VLESS,
|
||||
Protocols.TROJAN,
|
||||
Protocols.TROJAN,
|
||||
Protocols.SHADOWSOCKS
|
||||
].includes(this.inbound.protocol)
|
||||
) {
|
||||
@@ -797,9 +762,9 @@
|
||||
this.infoModal.clientIps = 'No IP Record';
|
||||
this.infoModal.clientIpsArray = [];
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch(() => { });
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -5,10 +5,9 @@
|
||||
{{template "form/inbound"}}
|
||||
</a-modal>
|
||||
<script>
|
||||
|
||||
// Make inModal globally available to ensure it works with any base path
|
||||
const inModal = window.inModal = {
|
||||
title: '',
|
||||
const inModal = (window.inModal = {
|
||||
title: "",
|
||||
visible: false,
|
||||
confirmLoading: false,
|
||||
okText: '{{ i18n "sure" }}',
|
||||
@@ -19,7 +18,14 @@
|
||||
ok() {
|
||||
ObjectUtil.execute(inModal.confirm, inModal.inbound, inModal.dbInbound);
|
||||
},
|
||||
show({ title = '', okText = '{{ i18n "sure" }}', inbound = null, dbInbound = null, confirm = (inbound, dbInbound) => { }, isEdit = false }) {
|
||||
show({
|
||||
title = "",
|
||||
okText = '{{ i18n "sure" }}',
|
||||
inbound = null,
|
||||
dbInbound = null,
|
||||
confirm = (inbound, dbInbound) => { },
|
||||
isEdit = false,
|
||||
}) {
|
||||
this.title = title;
|
||||
this.okText = okText;
|
||||
if (inbound) {
|
||||
@@ -30,7 +36,11 @@
|
||||
// Always ensure testseed is initialized for VLESS protocol (even if vision flow is not set yet)
|
||||
// This ensures Vue reactivity works properly
|
||||
if (this.inbound.protocol === Protocols.VLESS && this.inbound.settings) {
|
||||
if (!this.inbound.settings.testseed || !Array.isArray(this.inbound.settings.testseed) || this.inbound.settings.testseed.length < 4) {
|
||||
if (
|
||||
!this.inbound.settings.testseed ||
|
||||
!Array.isArray(this.inbound.settings.testseed) ||
|
||||
this.inbound.settings.testseed.length < 4
|
||||
) {
|
||||
// Create a new array to ensure Vue reactivity
|
||||
this.inbound.settings.testseed = [900, 500, 900, 256].slice();
|
||||
}
|
||||
@@ -56,7 +66,10 @@
|
||||
// Use inModal.inbound explicitly to ensure correct context
|
||||
if (!inModal.inbound || !inModal.inbound.settings) return;
|
||||
// Ensure testseed is initialized
|
||||
if (!inModal.inbound.settings.testseed || !Array.isArray(inModal.inbound.settings.testseed)) {
|
||||
if (
|
||||
!inModal.inbound.settings.testseed ||
|
||||
!Array.isArray(inModal.inbound.settings.testseed)
|
||||
) {
|
||||
inModal.inbound.settings.testseed = [900, 500, 900, 256];
|
||||
}
|
||||
// Ensure array has enough elements
|
||||
@@ -70,26 +83,35 @@
|
||||
// Use inModal.inbound explicitly to ensure correct context
|
||||
if (!inModal.inbound || !inModal.inbound.settings) return;
|
||||
// Ensure testseed is initialized
|
||||
if (!inModal.inbound.settings.testseed || !Array.isArray(inModal.inbound.settings.testseed) || inModal.inbound.settings.testseed.length < 4) {
|
||||
if (
|
||||
!inModal.inbound.settings.testseed ||
|
||||
!Array.isArray(inModal.inbound.settings.testseed) ||
|
||||
inModal.inbound.settings.testseed.length < 4
|
||||
) {
|
||||
inModal.inbound.settings.testseed = [900, 500, 900, 256].slice();
|
||||
}
|
||||
// Create new array with random values
|
||||
inModal.inbound.settings.testseed = [Math.floor(Math.random()*1000), Math.floor(Math.random()*1000), Math.floor(Math.random()*1000), Math.floor(Math.random()*1000)];
|
||||
inModal.inbound.settings.testseed = [
|
||||
Math.floor(Math.random() * 1000),
|
||||
Math.floor(Math.random() * 1000),
|
||||
Math.floor(Math.random() * 1000),
|
||||
Math.floor(Math.random() * 1000),
|
||||
];
|
||||
},
|
||||
resetTestseed() {
|
||||
// Use inModal.inbound explicitly to ensure correct context
|
||||
if (!inModal.inbound || !inModal.inbound.settings) return;
|
||||
// Reset testseed to default values
|
||||
inModal.inbound.settings.testseed = [900, 500, 900, 256].slice();
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Store Vue instance globally to ensure methods are always accessible
|
||||
let inboundModalVueInstance = null;
|
||||
|
||||
|
||||
inboundModalVueInstance = new Vue({
|
||||
delimiters: ['[[', ']]'],
|
||||
el: '#inbound-modal',
|
||||
delimiters: ["[[", "]]"],
|
||||
el: "#inbound-modal",
|
||||
data: {
|
||||
inModal: inModal,
|
||||
delayedStart: false,
|
||||
@@ -103,13 +125,19 @@
|
||||
return inModal.isEdit;
|
||||
},
|
||||
get client() {
|
||||
return inModal.inbound && inModal.inbound.clients && inModal.inbound.clients.length > 0 ? inModal.inbound.clients[0] : null;
|
||||
return inModal.inbound &&
|
||||
inModal.inbound.clients &&
|
||||
inModal.inbound.clients.length > 0
|
||||
? inModal.inbound.clients[0]
|
||||
: null;
|
||||
},
|
||||
get datepicker() {
|
||||
return app.datepicker;
|
||||
},
|
||||
get delayedExpireDays() {
|
||||
return this.client && this.client.expiryTime < 0 ? this.client.expiryTime / -86400000 : 0;
|
||||
return this.client && this.client.expiryTime < 0
|
||||
? this.client.expiryTime / -86400000
|
||||
: 0;
|
||||
},
|
||||
set delayedExpireDays(days) {
|
||||
this.client.expiryTime = -86400000 * days;
|
||||
@@ -119,72 +147,103 @@
|
||||
},
|
||||
set externalProxy(value) {
|
||||
if (value) {
|
||||
inModal.inbound.stream.externalProxy = [{
|
||||
forceTls: "same",
|
||||
dest: window.location.hostname,
|
||||
port: inModal.inbound.port,
|
||||
remark: ""
|
||||
}];
|
||||
inModal.inbound.stream.externalProxy = [
|
||||
{
|
||||
forceTls: "same",
|
||||
dest: window.location.hostname,
|
||||
port: inModal.inbound.port,
|
||||
remark: "",
|
||||
},
|
||||
];
|
||||
} else {
|
||||
inModal.inbound.stream.externalProxy = [];
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
'inModal.inbound.stream.security'(newVal, oldVal) {
|
||||
"inModal.inbound.stream.security"(newVal, oldVal) {
|
||||
// Clear flow when security changes from reality/tls to none
|
||||
if (inModal.inbound.protocol == Protocols.VLESS && !inModal.inbound.canEnableTlsFlow()) {
|
||||
inModal.inbound.settings.vlesses.forEach(client => {
|
||||
if (
|
||||
inModal.inbound.protocol == Protocols.VLESS &&
|
||||
!inModal.inbound.canEnableTlsFlow()
|
||||
) {
|
||||
inModal.inbound.settings.vlesses.forEach((client) => {
|
||||
client.flow = "";
|
||||
});
|
||||
}
|
||||
},
|
||||
// Ensure testseed is always initialized when vision flow is enabled
|
||||
'inModal.inbound.settings.vlesses': {
|
||||
"inModal.inbound.settings.vlesses": {
|
||||
handler() {
|
||||
if (inModal.inbound.protocol === Protocols.VLESS && inModal.inbound.settings && inModal.inbound.settings.vlesses) {
|
||||
const hasVisionFlow = inModal.inbound.settings.vlesses.some(c => c.flow === 'xtls-rprx-vision' || c.flow === 'xtls-rprx-vision-udp443');
|
||||
if (hasVisionFlow && (!inModal.inbound.settings.testseed || !Array.isArray(inModal.inbound.settings.testseed) || inModal.inbound.settings.testseed.length < 4)) {
|
||||
if (
|
||||
inModal.inbound.protocol === Protocols.VLESS &&
|
||||
inModal.inbound.settings &&
|
||||
inModal.inbound.settings.vlesses
|
||||
) {
|
||||
const hasVisionFlow = inModal.inbound.settings.vlesses.some(
|
||||
(c) =>
|
||||
c.flow === "xtls-rprx-vision" ||
|
||||
c.flow === "xtls-rprx-vision-udp443",
|
||||
);
|
||||
if (
|
||||
hasVisionFlow &&
|
||||
(!inModal.inbound.settings.testseed ||
|
||||
!Array.isArray(inModal.inbound.settings.testseed) ||
|
||||
inModal.inbound.settings.testseed.length < 4)
|
||||
) {
|
||||
inModal.inbound.settings.testseed = [900, 500, 900, 256];
|
||||
}
|
||||
}
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
deep: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
streamNetworkChange() {
|
||||
if (!inModal.inbound.canEnableTls()) {
|
||||
this.inModal.inbound.stream.security = 'none';
|
||||
this.inModal.inbound.stream.security = "none";
|
||||
}
|
||||
if (!inModal.inbound.canEnableReality()) {
|
||||
this.inModal.inbound.reality = false;
|
||||
}
|
||||
if (this.inModal.inbound.protocol == Protocols.VLESS && !inModal.inbound.canEnableTlsFlow()) {
|
||||
this.inModal.inbound.settings.vlesses.forEach(client => {
|
||||
if (
|
||||
this.inModal.inbound.protocol == Protocols.VLESS &&
|
||||
!inModal.inbound.canEnableTlsFlow()
|
||||
) {
|
||||
this.inModal.inbound.settings.vlesses.forEach((client) => {
|
||||
client.flow = "";
|
||||
});
|
||||
}
|
||||
if (inModal.inbound.stream.network != "kcp") {
|
||||
inModal.inbound.stream.finalmask.udp = [];
|
||||
}
|
||||
},
|
||||
SSMethodChange() {
|
||||
this.inModal.inbound.settings.password = RandomUtil.randomShadowsocksPassword(this.inModal.inbound.settings.method)
|
||||
this.inModal.inbound.settings.password =
|
||||
RandomUtil.randomShadowsocksPassword(
|
||||
this.inModal.inbound.settings.method,
|
||||
);
|
||||
|
||||
if (this.inModal.inbound.isSSMultiUser) {
|
||||
if (this.inModal.inbound.settings.shadowsockses.length == 0) {
|
||||
this.inModal.inbound.settings.shadowsockses = [new Inbound.ShadowsocksSettings.Shadowsocks()];
|
||||
this.inModal.inbound.settings.shadowsockses = [
|
||||
new Inbound.ShadowsocksSettings.Shadowsocks(),
|
||||
];
|
||||
}
|
||||
if (!this.inModal.inbound.isSS2022) {
|
||||
this.inModal.inbound.settings.shadowsockses.forEach(client => {
|
||||
this.inModal.inbound.settings.shadowsockses.forEach((client) => {
|
||||
client.method = this.inModal.inbound.settings.method;
|
||||
})
|
||||
});
|
||||
} else {
|
||||
this.inModal.inbound.settings.shadowsockses.forEach(client => {
|
||||
this.inModal.inbound.settings.shadowsockses.forEach((client) => {
|
||||
client.method = "";
|
||||
})
|
||||
});
|
||||
}
|
||||
this.inModal.inbound.settings.shadowsockses.forEach(client => {
|
||||
client.password = RandomUtil.randomShadowsocksPassword(this.inModal.inbound.settings.method)
|
||||
})
|
||||
this.inModal.inbound.settings.shadowsockses.forEach((client) => {
|
||||
client.password = RandomUtil.randomShadowsocksPassword(
|
||||
this.inModal.inbound.settings.method,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
if (this.inModal.inbound.settings.shadowsockses.length > 0) {
|
||||
this.inModal.inbound.settings.shadowsockses = [];
|
||||
@@ -197,7 +256,7 @@
|
||||
},
|
||||
async getNewX25519Cert() {
|
||||
inModal.loading(true);
|
||||
const msg = await HttpUtil.get('/panel/api/server/getNewX25519Cert');
|
||||
const msg = await HttpUtil.get("/panel/api/server/getNewX25519Cert");
|
||||
inModal.loading(false);
|
||||
if (!msg.success) {
|
||||
return;
|
||||
@@ -206,12 +265,12 @@
|
||||
inModal.inbound.stream.reality.settings.publicKey = msg.obj.publicKey;
|
||||
},
|
||||
clearX25519Cert() {
|
||||
this.inbound.stream.reality.privateKey = '';
|
||||
this.inbound.stream.reality.settings.publicKey = '';
|
||||
this.inbound.stream.reality.privateKey = "";
|
||||
this.inbound.stream.reality.settings.publicKey = "";
|
||||
},
|
||||
async getNewmldsa65() {
|
||||
inModal.loading(true);
|
||||
const msg = await HttpUtil.get('/panel/api/server/getNewmldsa65');
|
||||
const msg = await HttpUtil.get("/panel/api/server/getNewmldsa65");
|
||||
inModal.loading(false);
|
||||
if (!msg.success) {
|
||||
return;
|
||||
@@ -220,11 +279,11 @@
|
||||
inModal.inbound.stream.reality.settings.mldsa65Verify = msg.obj.verify;
|
||||
},
|
||||
clearMldsa65() {
|
||||
this.inbound.stream.reality.mldsa65Seed = '';
|
||||
this.inbound.stream.reality.settings.mldsa65Verify = '';
|
||||
this.inbound.stream.reality.mldsa65Seed = "";
|
||||
this.inbound.stream.reality.settings.mldsa65Verify = "";
|
||||
},
|
||||
randomizeRealityTarget() {
|
||||
if (typeof getRandomRealityTarget !== 'undefined') {
|
||||
if (typeof getRandomRealityTarget !== "undefined") {
|
||||
const randomTarget = getRandomRealityTarget();
|
||||
this.inbound.stream.reality.target = randomTarget.target;
|
||||
this.inbound.stream.reality.serverNames = randomTarget.sni;
|
||||
@@ -232,21 +291,24 @@
|
||||
},
|
||||
async getNewEchCert() {
|
||||
inModal.loading(true);
|
||||
const msg = await HttpUtil.post('/panel/api/server/getNewEchCert', { sni: inModal.inbound.stream.tls.sni });
|
||||
const msg = await HttpUtil.post("/panel/api/server/getNewEchCert", {
|
||||
sni: inModal.inbound.stream.tls.sni,
|
||||
});
|
||||
inModal.loading(false);
|
||||
if (!msg.success) {
|
||||
return;
|
||||
}
|
||||
inModal.inbound.stream.tls.echServerKeys = msg.obj.echServerKeys;
|
||||
inModal.inbound.stream.tls.settings.echConfigList = msg.obj.echConfigList;
|
||||
inModal.inbound.stream.tls.settings.echConfigList =
|
||||
msg.obj.echConfigList;
|
||||
},
|
||||
clearEchCert() {
|
||||
this.inbound.stream.tls.echServerKeys = '';
|
||||
this.inbound.stream.tls.settings.echConfigList = '';
|
||||
this.inbound.stream.tls.echServerKeys = "";
|
||||
this.inbound.stream.tls.settings.echConfigList = "";
|
||||
},
|
||||
async getNewVlessEnc() {
|
||||
inModal.loading(true);
|
||||
const msg = await HttpUtil.get('/panel/api/server/getNewVlessEnc');
|
||||
const msg = await HttpUtil.get("/panel/api/server/getNewVlessEnc");
|
||||
inModal.loading(false);
|
||||
|
||||
if (!msg.success) {
|
||||
@@ -255,7 +317,7 @@
|
||||
|
||||
const auths = msg.obj.auths || [];
|
||||
const selected = inModal.inbound.settings.selectedAuth;
|
||||
const block = auths.find(a => a.label === selected);
|
||||
const block = auths.find((a) => a.label === selected);
|
||||
|
||||
if (!block) {
|
||||
console.error("No auth block for", selected);
|
||||
@@ -266,15 +328,18 @@
|
||||
inModal.inbound.settings.encryption = block.encryption;
|
||||
},
|
||||
clearVlessEnc() {
|
||||
this.inbound.settings.decryption = 'none';
|
||||
this.inbound.settings.encryption = 'none';
|
||||
this.inbound.settings.decryption = "none";
|
||||
this.inbound.settings.encryption = "none";
|
||||
this.inbound.settings.selectedAuth = undefined;
|
||||
},
|
||||
// Vision Seed methods - must be in Vue methods for proper binding
|
||||
updateTestseed(index, value) {
|
||||
// Ensure testseed is initialized
|
||||
if (!this.inbound.settings.testseed || !Array.isArray(this.inbound.settings.testseed)) {
|
||||
this.$set(this.inbound.settings, 'testseed', [900, 500, 900, 256]);
|
||||
if (
|
||||
!this.inbound.settings.testseed ||
|
||||
!Array.isArray(this.inbound.settings.testseed)
|
||||
) {
|
||||
this.$set(this.inbound.settings, "testseed", [900, 500, 900, 256]);
|
||||
}
|
||||
// Ensure array has enough elements
|
||||
while (this.inbound.settings.testseed.length <= index) {
|
||||
@@ -285,15 +350,19 @@
|
||||
},
|
||||
setRandomTestseed() {
|
||||
// Create new array with random values and use Vue.set for reactivity
|
||||
const newSeed = [Math.floor(Math.random()*1000), Math.floor(Math.random()*1000), Math.floor(Math.random()*1000), Math.floor(Math.random()*1000)];
|
||||
this.$set(this.inbound.settings, 'testseed', newSeed);
|
||||
const newSeed = [
|
||||
Math.floor(Math.random() * 1000),
|
||||
Math.floor(Math.random() * 1000),
|
||||
Math.floor(Math.random() * 1000),
|
||||
Math.floor(Math.random() * 1000),
|
||||
];
|
||||
this.$set(this.inbound.settings, "testseed", newSeed);
|
||||
},
|
||||
resetTestseed() {
|
||||
// Reset testseed to default values using Vue.set for reactivity
|
||||
this.$set(this.inbound.settings, 'testseed', [900, 500, 900, 256]);
|
||||
}
|
||||
this.$set(this.inbound.settings, "testseed", [900, 500, 900, 256]);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,306 @@
|
||||
{{define "modals/nordModal"}}
|
||||
<a-modal id="nord-modal" v-model="nordModal.visible" title="NordVPN NordLynx"
|
||||
:confirm-loading="nordModal.confirmLoading" :closable="true" :mask-closable="true"
|
||||
:footer="null" :class="themeSwitcher.currentTheme">
|
||||
<template v-if="nordModal.nordData == null">
|
||||
<a-tabs default-active-key="token" :class="themeSwitcher.currentTheme">
|
||||
<a-tab-pane key="token" tab='{{ i18n "pages.xray.outbound.accessToken" }}'>
|
||||
<a-form :colon="false" :label-col="{ md: {span:6} }" :wrapper-col="{ md: {span:18} }" :style="{ marginTop: '20px' }">
|
||||
<a-form-item label='{{ i18n "pages.xray.outbound.accessToken" }}'>
|
||||
<a-input v-model="nordModal.token" placeholder='{{ i18n "pages.xray.outbound.accessToken" }}'></a-input>
|
||||
<div :style="{ marginTop: '10px' }">
|
||||
<a-button type="primary" icon="login" @click="login()" :loading="nordModal.confirmLoading">{{ i18n "login" }}</a-button>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="key" tab='{{ i18n "pages.xray.outbound.privateKey" }}'>
|
||||
<a-form :colon="false" :label-col="{ md: {span:6} }" :wrapper-col="{ md: {span:18} }" :style="{ marginTop: '20px' }">
|
||||
<a-form-item label='{{ i18n "pages.xray.outbound.privateKey" }}'>
|
||||
<a-input v-model="nordModal.manualKey" placeholder='{{ i18n "pages.xray.outbound.privateKey" }}'></a-input>
|
||||
<div :style="{ marginTop: '10px' }">
|
||||
<a-button type="primary" icon="save" @click="saveKey()" :loading="nordModal.confirmLoading">{{ i18n "save" }}</a-button>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</template>
|
||||
<template v-else>
|
||||
<table :style="{ margin: '5px 0', width: '100%' }">
|
||||
<tr class="client-table-odd-row" v-if="nordModal.nordData.token">
|
||||
<td>{{ i18n "pages.xray.outbound.accessToken" }}</td>
|
||||
<td>[[ nordModal.nordData.token ]]</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ i18n "pages.xray.outbound.privateKey" }}</td>
|
||||
<td>[[ nordModal.nordData.private_key ]]</td>
|
||||
</tr>
|
||||
</table>
|
||||
<a-button @click="logout" :loading="nordModal.confirmLoading" type="danger">{{ i18n "logout" }}</a-button>
|
||||
<a-divider :style="{ margin: '0' }">{{ i18n "pages.xray.outbound.settings" }}</a-divider>
|
||||
<a-form :colon="false" :label-col="{ md: {span:6} }" :wrapper-col="{ md: {span:18} }" :style="{ marginTop: '10px' }">
|
||||
<a-form-item label='{{ i18n "pages.xray.outbound.country" }}'>
|
||||
<a-select v-model="nordModal.countryId" @change="fetchServers" show-search option-filter-prop="label">
|
||||
<a-select-option v-for="c in nordModal.countries" :key="c.id" :value="c.id" :label="c.name">
|
||||
[[ c.name ]] ([[ c.code ]])
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.xray.outbound.city" }}' v-if="nordModal.cities.length > 0">
|
||||
<a-select v-model="nordModal.cityId" @change="onCityChange" show-search option-filter-prop="label">
|
||||
<a-select-option :key="0" :value="null" label='{{ i18n "pages.xray.outbound.allCities" }}'>
|
||||
{{ i18n "pages.xray.outbound.allCities" }}
|
||||
</a-select-option>
|
||||
<a-select-option v-for="c in nordModal.cities" :key="c.id" :value="c.id" :label="c.name">
|
||||
[[ c.name ]]
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.xray.outbound.server" }}' v-if="filteredServers.length > 0">
|
||||
<a-select v-model="nordModal.serverId">
|
||||
<a-select-option v-for="s in filteredServers" :key="s.id" :value="s.id">
|
||||
[[ s.cityName ]] - [[ s.name ]] ({{ i18n "pages.xray.outbound.load" }}: [[ s.load ]]%)
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<a-divider :style="{ margin: '10px 0' }">{{ i18n "pages.xray.outbound.outboundStatus" }}</a-divider>
|
||||
<a-form :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<template v-if="nordOutboundIndex>=0">
|
||||
<a-tag color="green" :style="{ lineHeight: '31px' }">{{ i18n "enabled" }}</a-tag>
|
||||
<a-button @click="resetOutbound" :loading="nordModal.confirmLoading" type="danger">{{ i18n "reset" }}</a-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-tag color="orange" :style="{ lineHeight: '31px' }">{{ i18n "disabled" }}</a-tag>
|
||||
<a-button @click="addOutbound" :disabled="!nordModal.serverId" :loading="nordModal.confirmLoading" type="primary">{{ i18n "pages.xray.outbound.addOutbound" }}</a-button>
|
||||
</template>
|
||||
</a-form>
|
||||
</template>
|
||||
</a-modal>
|
||||
|
||||
<script>
|
||||
const nordModal = {
|
||||
visible: false,
|
||||
confirmLoading: false,
|
||||
nordData: null,
|
||||
token: '',
|
||||
manualKey: '',
|
||||
countries: [],
|
||||
countryId: null,
|
||||
cities: [],
|
||||
cityId: null,
|
||||
servers: [],
|
||||
serverId: null,
|
||||
show() {
|
||||
this.visible = true;
|
||||
this.getData();
|
||||
},
|
||||
close() {
|
||||
this.visible = false;
|
||||
},
|
||||
loading(loading = true) {
|
||||
this.confirmLoading = loading;
|
||||
},
|
||||
async getData() {
|
||||
this.loading(true);
|
||||
const msg = await HttpUtil.post('/panel/xray/nord/data');
|
||||
if (msg.success) {
|
||||
this.nordData = msg.obj ? JSON.parse(msg.obj) : null;
|
||||
if (this.nordData) {
|
||||
await this.fetchCountries();
|
||||
}
|
||||
}
|
||||
this.loading(false);
|
||||
},
|
||||
async login() {
|
||||
this.loading(true);
|
||||
const msg = await HttpUtil.post('/panel/xray/nord/reg', { token: this.token });
|
||||
if (msg.success) {
|
||||
this.nordData = JSON.parse(msg.obj);
|
||||
await this.fetchCountries();
|
||||
}
|
||||
this.loading(false);
|
||||
},
|
||||
async saveKey() {
|
||||
this.loading(true);
|
||||
const msg = await HttpUtil.post('/panel/xray/nord/setKey', { key: this.manualKey });
|
||||
if (msg.success) {
|
||||
this.nordData = JSON.parse(msg.obj);
|
||||
await this.fetchCountries();
|
||||
}
|
||||
this.loading(false);
|
||||
},
|
||||
async logout(index) {
|
||||
this.loading(true);
|
||||
const msg = await HttpUtil.post('/panel/xray/nord/del');
|
||||
if (msg.success) {
|
||||
this.delOutbound(index);
|
||||
this.delRouting();
|
||||
this.nordData = null;
|
||||
this.token = '';
|
||||
this.manualKey = '';
|
||||
this.countries = [];
|
||||
this.cities = [];
|
||||
this.servers = [];
|
||||
this.countryId = null;
|
||||
this.cityId = null;
|
||||
}
|
||||
this.loading(false);
|
||||
},
|
||||
async fetchCountries() {
|
||||
const msg = await HttpUtil.post('/panel/xray/nord/countries');
|
||||
if (msg.success) {
|
||||
this.countries = JSON.parse(msg.obj);
|
||||
}
|
||||
},
|
||||
async fetchServers() {
|
||||
this.loading(true);
|
||||
this.servers = [];
|
||||
this.cities = [];
|
||||
this.serverId = null;
|
||||
this.cityId = null;
|
||||
const msg = await HttpUtil.post('/panel/xray/nord/servers', { countryId: this.countryId });
|
||||
if (msg.success) {
|
||||
const data = JSON.parse(msg.obj);
|
||||
const locations = data.locations || [];
|
||||
const locToCity = {};
|
||||
const citiesMap = new Map();
|
||||
locations.forEach(loc => {
|
||||
if (loc.country && loc.country.city) {
|
||||
citiesMap.set(loc.country.city.id, loc.country.city);
|
||||
locToCity[loc.id] = loc.country.city;
|
||||
}
|
||||
});
|
||||
this.cities = Array.from(citiesMap.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
this.servers = (data.servers || []).map(s => {
|
||||
const firstLocId = (s.location_ids || [])[0];
|
||||
const city = locToCity[firstLocId];
|
||||
s.cityId = city ? city.id : null;
|
||||
s.cityName = city ? city.name : 'Unknown';
|
||||
return s;
|
||||
}).sort((a, b) => a.load - b.load);
|
||||
|
||||
if (this.servers.length > 0) {
|
||||
this.serverId = this.servers[0].id;
|
||||
}
|
||||
|
||||
if (this.servers.length === 0) {
|
||||
app.$message.warning('No servers found for the selected country');
|
||||
}
|
||||
}
|
||||
this.loading(false);
|
||||
},
|
||||
addOutbound() {
|
||||
const server = this.servers.find(s => s.id === this.serverId);
|
||||
if (!server) return;
|
||||
|
||||
const tech = server.technologies.find(t => t.id === 35);
|
||||
const publicKey = tech.metadata.find(m => m.name === 'public_key').value;
|
||||
|
||||
const outbound = {
|
||||
tag: `nord-${server.hostname}`,
|
||||
protocol: 'wireguard',
|
||||
settings: {
|
||||
secretKey: this.nordData.private_key,
|
||||
address: ['10.5.0.2/32'],
|
||||
peers: [{
|
||||
publicKey: publicKey,
|
||||
endpoint: server.station + ':51820'
|
||||
}],
|
||||
noKernelTun: false
|
||||
}
|
||||
};
|
||||
|
||||
app.templateSettings.outbounds.push(outbound);
|
||||
app.outboundSettings = JSON.stringify(app.templateSettings.outbounds);
|
||||
this.close();
|
||||
app.$message.success('NordVPN outbound added');
|
||||
},
|
||||
resetOutbound(index) {
|
||||
const server = this.servers.find(s => s.id === this.serverId);
|
||||
if (!server || index === -1) return;
|
||||
|
||||
const tech = server.technologies.find(t => t.id === 35);
|
||||
const publicKey = tech.metadata.find(m => m.name === 'public_key').value;
|
||||
|
||||
const oldTag = app.templateSettings.outbounds[index].tag;
|
||||
const newTag = `nord-${server.hostname}`;
|
||||
|
||||
const outbound = {
|
||||
tag: newTag,
|
||||
protocol: 'wireguard',
|
||||
settings: {
|
||||
secretKey: this.nordData.private_key,
|
||||
address: ['10.5.0.2/32'],
|
||||
peers: [{
|
||||
publicKey: publicKey,
|
||||
endpoint: server.station + ':51820'
|
||||
}],
|
||||
noKernelTun: false
|
||||
}
|
||||
};
|
||||
app.templateSettings.outbounds[index] = outbound;
|
||||
|
||||
// Sync routing rules
|
||||
app.templateSettings.routing.rules.forEach(r => {
|
||||
if (r.outboundTag === oldTag) {
|
||||
r.outboundTag = newTag;
|
||||
}
|
||||
});
|
||||
|
||||
app.outboundSettings = JSON.stringify(app.templateSettings.outbounds);
|
||||
this.close();
|
||||
app.$message.success('NordVPN outbound updated');
|
||||
},
|
||||
delOutbound(index) {
|
||||
if (index !== -1) {
|
||||
app.templateSettings.outbounds.splice(index, 1);
|
||||
app.outboundSettings = JSON.stringify(app.templateSettings.outbounds);
|
||||
}
|
||||
},
|
||||
delRouting() {
|
||||
if (app.templateSettings && app.templateSettings.routing) {
|
||||
app.templateSettings.routing.rules = app.templateSettings.routing.rules.filter(r => !r.outboundTag.startsWith("nord-"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
new Vue({
|
||||
delimiters: ['[[', ']]'],
|
||||
el: '#nord-modal',
|
||||
data: {
|
||||
nordModal: nordModal,
|
||||
},
|
||||
methods: {
|
||||
login: () => nordModal.login(),
|
||||
saveKey: () => nordModal.saveKey(),
|
||||
logout() { nordModal.logout(this.nordOutboundIndex) },
|
||||
fetchServers: () => nordModal.fetchServers(),
|
||||
addOutbound: () => nordModal.addOutbound(),
|
||||
resetOutbound() { nordModal.resetOutbound(this.nordOutboundIndex) },
|
||||
onCityChange() {
|
||||
if (this.filteredServers.length > 0) {
|
||||
this.nordModal.serverId = this.filteredServers[0].id;
|
||||
} else {
|
||||
this.nordModal.serverId = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
nordOutboundIndex: {
|
||||
get: function () {
|
||||
return app.templateSettings ? app.templateSettings.outbounds.findIndex((o) => o.tag.startsWith("nord-")) : -1;
|
||||
}
|
||||
},
|
||||
filteredServers: function() {
|
||||
if (!this.nordModal.cityId) {
|
||||
return this.nordModal.servers;
|
||||
}
|
||||
return this.nordModal.servers.filter(s => s.cityId === this.nordModal.cityId);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -6,7 +6,7 @@
|
||||
<a-sidebar></a-sidebar>
|
||||
<a-layout id="content-layout">
|
||||
<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>
|
||||
<a-alert type="error" v-if="confAlerts.length>0 && loadingStates.fetched" :style="{ marginBottom: '10px' }"
|
||||
message='{{ i18n "secAlertTitle" }}' color="red" show-icon closable>
|
||||
@@ -21,10 +21,7 @@
|
||||
<transition name="list" appear>
|
||||
<template>
|
||||
<a-row v-if="!loadingStates.fetched">
|
||||
<a-card
|
||||
:style="{ textAlign: 'center', padding: '30px 0', marginTop: '10px', background: 'transparent', border: 'none' }">
|
||||
<a-spin tip='{{ i18n "loading" }}'></a-spin>
|
||||
</a-card>
|
||||
<div :style="{ minHeight: 'calc(100vh - 120px)' }"></div>
|
||||
</a-row>
|
||||
<a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-else>
|
||||
<a-col>
|
||||
@@ -82,10 +79,10 @@
|
||||
</template>
|
||||
{{ template "settings/panel/subscription/general" . }}
|
||||
</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>
|
||||
<a-icon type="code"></a-icon>
|
||||
<span>{{ i18n "pages.settings.subSettings" }} (JSON)</span>
|
||||
<span>{{ i18n "pages.settings.subSettings" }} (Formats)</span>
|
||||
</template>
|
||||
{{ template "settings/panel/subscription/json" . }}
|
||||
</a-tab-pane>
|
||||
|
||||
@@ -3,43 +3,58 @@
|
||||
<a-collapse-panel key="1" header='{{ i18n "pages.xray.generalConfigs"}}'>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.subEnable"}}</template>
|
||||
<template #description>{{ i18n "pages.settings.subEnableDesc"}}</template>
|
||||
<template #description>{{ i18n
|
||||
"pages.settings.subEnableDesc"}}</template>
|
||||
<template #control>
|
||||
<a-switch v-model="allSetting.subEnable"></a-switch>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>JSON Subscription</template>
|
||||
<template #description>{{ i18n "pages.settings.subJsonEnable"}}</template>
|
||||
<template #description>{{ i18n
|
||||
"pages.settings.subJsonEnable"}}</template>
|
||||
<template #control>
|
||||
<a-switch v-model="allSetting.subJsonEnable"></a-switch>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<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">
|
||||
<template #title>{{ i18n "pages.settings.subListen"}}</template>
|
||||
<template #description>{{ i18n "pages.settings.subListenDesc"}}</template>
|
||||
<template #description>{{ i18n
|
||||
"pages.settings.subListenDesc"}}</template>
|
||||
<template #control>
|
||||
<a-input type="text" v-model="allSetting.subListen"></a-input>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.subDomain"}}</template>
|
||||
<template #description>{{ i18n "pages.settings.subDomainDesc"}}</template>
|
||||
<template #description>{{ i18n
|
||||
"pages.settings.subDomainDesc"}}</template>
|
||||
<template #control>
|
||||
<a-input type="text" v-model="allSetting.subDomain"></a-input>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.subPort"}}</template>
|
||||
<template #description>{{ i18n "pages.settings.subPortDesc"}}</template>
|
||||
<template #description>{{ i18n
|
||||
"pages.settings.subPortDesc"}}</template>
|
||||
<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>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.subPath"}}</template>
|
||||
<template #description>{{ i18n "pages.settings.subPathDesc"}}</template>
|
||||
<template #description>{{ i18n
|
||||
"pages.settings.subPathDesc"}}</template>
|
||||
<template #control>
|
||||
<a-input type="text" v-model="allSetting.subPath"
|
||||
@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 paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.subURI"}}</template>
|
||||
<template #description>{{ i18n "pages.settings.subURIDesc"}}</template>
|
||||
<template #description>{{ i18n
|
||||
"pages.settings.subURIDesc"}}</template>
|
||||
<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>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
@@ -59,14 +76,16 @@
|
||||
<a-collapse-panel key="2" header='{{ i18n "pages.settings.information" }}'>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.subEncrypt"}}</template>
|
||||
<template #description>{{ i18n "pages.settings.subEncryptDesc"}}</template>
|
||||
<template #description>{{ i18n
|
||||
"pages.settings.subEncryptDesc"}}</template>
|
||||
<template #control>
|
||||
<a-switch v-model="allSetting.subEncrypt"></a-switch>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.subShowInfo"}}</template>
|
||||
<template #description>{{ i18n "pages.settings.subShowInfoDesc"}}</template>
|
||||
<template #description>{{ i18n
|
||||
"pages.settings.subShowInfoDesc"}}</template>
|
||||
<template #control>
|
||||
<a-switch v-model="allSetting.subShowInfo"></a-switch>
|
||||
</template>
|
||||
@@ -74,59 +93,72 @@
|
||||
<a-divider>{{ i18n "pages.xray.basicTemplate"}}</a-divider>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.subTitle"}}</template>
|
||||
<template #description>{{ i18n "pages.settings.subTitleDesc"}}</template>
|
||||
<template #description>{{ i18n
|
||||
"pages.settings.subTitleDesc"}}</template>
|
||||
<template #control>
|
||||
<a-input type="text" v-model="allSetting.subTitle"></a-input>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.subSupportUrl"}}</template>
|
||||
<template #description>{{ i18n "pages.settings.subSupportUrlDesc"}}</template>
|
||||
<template #description>{{ i18n
|
||||
"pages.settings.subSupportUrlDesc"}}</template>
|
||||
<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>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.subProfileUrl"}}</template>
|
||||
<template #description>{{ i18n "pages.settings.subProfileUrlDesc"}}</template>
|
||||
<template #description>{{ i18n
|
||||
"pages.settings.subProfileUrlDesc"}}</template>
|
||||
<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>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.subAnnounce"}}</template>
|
||||
<template #description>{{ i18n "pages.settings.subAnnounceDesc"}}</template>
|
||||
<template #description>{{ i18n
|
||||
"pages.settings.subAnnounceDesc"}}</template>
|
||||
<template #control>
|
||||
<a-textarea v-model="allSetting.subAnnounce"></a-textarea>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-divider>{{ i18n "pages.xray.advancedTemplate"}} (Happ)</a-divider>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.subEnableRouting"}}</template>
|
||||
<template #description>{{ i18n "pages.settings.subEnableRoutingDesc"}}</template>
|
||||
<template #title>{{ i18n
|
||||
"pages.settings.subEnableRouting"}}</template>
|
||||
<template #description>{{ i18n
|
||||
"pages.settings.subEnableRoutingDesc"}}</template>
|
||||
<template #control>
|
||||
<a-switch v-model="allSetting.subEnableRouting"></a-switch>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.subRoutingRules"}}</template>
|
||||
<template #description>{{ i18n "pages.settings.subRoutingRulesDesc"}}</template>
|
||||
<template #title>{{ i18n
|
||||
"pages.settings.subRoutingRules"}}</template>
|
||||
<template #description>{{ i18n
|
||||
"pages.settings.subRoutingRulesDesc"}}</template>
|
||||
<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>
|
||||
</a-setting-list-item>
|
||||
</a-collapse-panel>
|
||||
<a-collapse-panel key="3" header='{{ i18n "pages.settings.certs" }}'>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.subCertPath"}}</template>
|
||||
<template #description>{{ i18n "pages.settings.subCertPathDesc"}}</template>
|
||||
<template #description>{{ i18n
|
||||
"pages.settings.subCertPathDesc"}}</template>
|
||||
<template #control>
|
||||
<a-input type="text" v-model="allSetting.subCertFile"></a-input>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.subKeyPath"}}</template>
|
||||
<template #description>{{ i18n "pages.settings.subKeyPathDesc"}}</template>
|
||||
<template #description>{{ i18n
|
||||
"pages.settings.subKeyPathDesc"}}</template>
|
||||
<template #control>
|
||||
<a-input type="text" v-model="allSetting.subKeyFile"></a-input>
|
||||
</template>
|
||||
@@ -135,9 +167,11 @@
|
||||
<a-collapse-panel key="4" header='{{ i18n "pages.settings.intervals"}}'>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.subUpdates"}}</template>
|
||||
<template #description>{{ i18n "pages.settings.subUpdatesDesc"}}</template>
|
||||
<template #description>{{ i18n
|
||||
"pages.settings.subUpdatesDesc"}}</template>
|
||||
<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>
|
||||
</a-setting-list-item>
|
||||
</a-collapse-panel>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{{define "settings/panel/subscription/json"}}
|
||||
<a-collapse default-active-key="1">
|
||||
<a-collapse-panel key="1" header='{{ i18n "pages.xray.generalConfigs"}}'>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.subPath"}}</template>
|
||||
<a-setting-list-item paddings="small" v-if="allSetting.subJsonEnable">
|
||||
<template #title>{{ i18n "pages.settings.subPath"}} (JSON)</template>
|
||||
<template #description>{{ i18n "pages.settings.subPathDesc"}}</template>
|
||||
<template #control>
|
||||
<a-input type="text" v-model="allSetting.subJsonPath"
|
||||
@@ -11,14 +11,32 @@
|
||||
placeholder="/json/"></a-input>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.subURI"}}</template>
|
||||
<a-setting-list-item paddings="small" v-if="allSetting.subJsonEnable">
|
||||
<template #title>{{ i18n "pages.settings.subURI"}} (JSON)</template>
|
||||
<template #description>{{ i18n "pages.settings.subURIDesc"}}</template>
|
||||
<template #control>
|
||||
<a-input type="text" placeholder="(http|https)://domain[:port]/path/"
|
||||
v-model="allSetting.subJsonURI"></a-input>
|
||||
</template>
|
||||
</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 key="2" header='{{ i18n "pages.settings.fragment"}}'>
|
||||
<a-setting-list-item paddings="small">
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
<a-form-item>
|
||||
<a-space direction="vertical" align="center">
|
||||
<a-row type="flex" :gutter="[8,8]" justify="center" style="width:100%">
|
||||
<a-col :xs="24" :sm="app.subJsonUrl ? 12 : 24" style="text-align:center;">
|
||||
<a-col :xs="24" :sm="app.subJsonUrl || app.subClashUrl ? 12 : 24" style="text-align:center;">
|
||||
<tr-qr-box class="qr-box">
|
||||
<a-tag color="purple" class="qr-tag">
|
||||
<span>{{ i18n
|
||||
@@ -112,6 +112,19 @@
|
||||
</tr-qr-bg>
|
||||
</tr-qr-box>
|
||||
</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-space>
|
||||
</a-form-item>
|
||||
@@ -242,7 +255,7 @@
|
||||
</a-layout>
|
||||
|
||||
<!-- 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-remained="{{ .remained }}" data-expire="{{ .expire }}" data-lastonline="{{ .lastOnline }}"
|
||||
data-downloadbyte="{{ .downloadByte }}" data-uploadbyte="{{ .uploadByte }}" data-totalbyte="{{ .totalByte }}"
|
||||
|
||||
@@ -313,6 +313,25 @@
|
||||
</template>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.xray.nordRouting" }}</template>
|
||||
<template #control>
|
||||
<template v-if="NordExist">
|
||||
<a-select mode="tags" :style="{ width: '100%' }"
|
||||
v-model="nordDomains"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option :value="p.value" :label="p.label"
|
||||
v-for="p in settingsData.ServicesOptions">
|
||||
<span>[[ p.label ]]</span>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-button type="primary" icon="api"
|
||||
@click="showNord()">{{ i18n "pages.xray.outbound.nordvpn" }}</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
</a-collapse-panel>
|
||||
<a-collapse-panel key="6"
|
||||
header='{{ i18n "pages.settings.resetDefaultConfig"}}'>
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
</a-button>
|
||||
<a-button type="primary" icon="cloud"
|
||||
@click="showWarp()">WARP</a-button>
|
||||
<a-button type="primary" icon="api"
|
||||
@click="showNord()">NordVPN</a-button>
|
||||
</a-space>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="12" :lg="12" :style="{ textAlign: 'right' }">
|
||||
|
||||
+59
-5
@@ -14,7 +14,7 @@
|
||||
<a-layout id="content-layout">
|
||||
<a-layout-content>
|
||||
<a-spin :spinning="loadingStates.spinning" :delay="500"
|
||||
tip='{{ i18n "loading"}}'>
|
||||
tip='{{ i18n "loading"}}' size="large">
|
||||
<transition name="list" appear>
|
||||
<a-alert type="error" v-if="showAlert && loadingStates.fetched"
|
||||
:style="{ marginBottom: '10px' }"
|
||||
@@ -24,10 +24,7 @@
|
||||
</transition>
|
||||
<transition name="list" appear>
|
||||
<a-row v-if="!loadingStates.fetched">
|
||||
<a-card
|
||||
:style="{ textAlign: 'center', padding: '30px 0', marginTop: '10px', background: 'transparent', border: 'none' }">
|
||||
<a-spin tip='{{ i18n "loading" }}'></a-spin>
|
||||
</a-card>
|
||||
<div :style="{ minHeight: 'calc(100vh - 120px)' }"></div>
|
||||
</a-row>
|
||||
<a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-else>
|
||||
<a-col>
|
||||
@@ -166,6 +163,7 @@
|
||||
{{template "modals/dnsPresetsModal"}}
|
||||
{{template "modals/fakednsModal"}}
|
||||
{{template "modals/warpModal"}}
|
||||
{{template "modals/nordModal"}}
|
||||
<script>
|
||||
const rulesColumns = [
|
||||
{ title: "#", align: 'center', width: 15, scopedSlots: { customRender: 'action' } },
|
||||
@@ -262,6 +260,7 @@
|
||||
refreshing: false,
|
||||
restartResult: '',
|
||||
showAlert: false,
|
||||
customGeoAliasLabelSuffix: '{{ i18n "pages.index.customGeoAliasLabelSuffix" }}',
|
||||
advSettings: 'xraySetting',
|
||||
obsSettings: '',
|
||||
cm: null,
|
||||
@@ -1057,6 +1056,34 @@
|
||||
},
|
||||
showWarp() {
|
||||
warpModal.show();
|
||||
},
|
||||
showNord() {
|
||||
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() {
|
||||
@@ -1064,6 +1091,7 @@
|
||||
this.showAlert = true;
|
||||
}
|
||||
await this.getXraySetting();
|
||||
await this.loadCustomGeoAliases();
|
||||
await this.getXrayResult();
|
||||
await this.getOutboundsTraffic();
|
||||
|
||||
@@ -1075,6 +1103,14 @@
|
||||
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) {
|
||||
@@ -1397,6 +1433,19 @@
|
||||
this.templateRuleSetter({ outboundTag: "warp", property: "domain", data: newValue });
|
||||
}
|
||||
},
|
||||
nordTag: {
|
||||
get: function () {
|
||||
return this.templateSettings ? (this.templateSettings.outbounds.find((o) => o.tag.startsWith("nord-")) || { tag: "nord" }).tag : "nord";
|
||||
}
|
||||
},
|
||||
nordDomains: {
|
||||
get: function () {
|
||||
return this.templateRuleGetter({ outboundTag: this.nordTag, property: "domain" });
|
||||
},
|
||||
set: function (newValue) {
|
||||
this.templateRuleSetter({ outboundTag: this.nordTag, property: "domain", data: newValue });
|
||||
}
|
||||
},
|
||||
torrentSettings: {
|
||||
get: function () {
|
||||
return ArrayUtils.doAllItemsExist(this.settingsData.protocols.bittorrent, this.blockedProtocols);
|
||||
@@ -1414,6 +1463,11 @@
|
||||
return this.templateSettings ? this.templateSettings.outbounds.findIndex((o) => o.tag == "warp") >= 0 : false;
|
||||
},
|
||||
},
|
||||
NordExist: {
|
||||
get: function () {
|
||||
return this.templateSettings ? this.templateSettings.outbounds.findIndex((o) => o.tag.startsWith("nord-")) >= 0 : false;
|
||||
},
|
||||
},
|
||||
enableDNS: {
|
||||
get: function () {
|
||||
return this.templateSettings ? this.templateSettings.dns != null : false;
|
||||
|
||||
@@ -3,6 +3,7 @@ package job
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
@@ -10,7 +11,6 @@ import (
|
||||
"regexp"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v2/database"
|
||||
@@ -33,6 +33,8 @@ type CheckClientIpJob struct {
|
||||
|
||||
var job *CheckClientIpJob
|
||||
|
||||
const defaultXrayAPIPort = 62789
|
||||
|
||||
// NewCheckClientIpJob creates a new client IP monitoring job instance.
|
||||
func NewCheckClientIpJob() *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))
|
||||
for ip, timestamp := range ipMap {
|
||||
allIps = append(allIps, IPWithTimestamp{IP: ip, Timestamp: timestamp})
|
||||
}
|
||||
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
|
||||
@@ -345,23 +348,23 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
|
||||
if len(allIps) > limitIp {
|
||||
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]
|
||||
disconnectedIps := allIps[limitIp:]
|
||||
bannedIps := allIps[limitIp:]
|
||||
|
||||
// Log the disconnected IPs (old ones)
|
||||
for _, ipTime := range disconnectedIps {
|
||||
// Log banned IPs in the format fail2ban filters expect: [LIMIT_IP] Email = X || Disconnecting OLD IP = Y || Timestamp = Z
|
||||
for _, ipTime := range bannedIps {
|
||||
j.disAllowedIps = append(j.disAllowedIps, ipTime.IP)
|
||||
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
|
||||
// This forces Xray to drop existing connections from old IPs
|
||||
if len(disconnectedIps) > 0 {
|
||||
// Actually disconnect banned IPs by temporarily removing and re-adding user
|
||||
// This forces Xray to drop existing connections from banned IPs
|
||||
if len(bannedIps) > 0 {
|
||||
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)
|
||||
inboundClientIps.Ips = string(jsonIps)
|
||||
} else {
|
||||
@@ -378,27 +381,16 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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) {
|
||||
var xrayAPI xray.XrayAPI
|
||||
|
||||
// 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
|
||||
}
|
||||
apiPort := j.resolveXrayAPIPort()
|
||||
|
||||
err := xrayAPI.Init(apiPort)
|
||||
if err != nil {
|
||||
@@ -422,6 +414,29 @@ func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, c
|
||||
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
|
||||
err = xrayAPI.RemoveUser(inbound.Tag, clientEmail)
|
||||
if err != nil {
|
||||
@@ -433,12 +448,69 @@ func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, c
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// 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 {
|
||||
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) {
|
||||
db := database.GetDB()
|
||||
inbound := &model.Inbound{}
|
||||
|
||||
@@ -37,7 +37,7 @@ func (j *PeriodicTrafficResetJob) Run() {
|
||||
resetCount := 0
|
||||
|
||||
for _, inbound := range inbounds {
|
||||
resetInboundErr := j.inboundService.ResetAllTraffics()
|
||||
resetInboundErr := j.inboundService.ResetInboundTraffic(inbound.Id)
|
||||
if resetInboundErr != nil {
|
||||
logger.Warning("Failed to reset traffic for inbound", inbound.Id, ":", resetInboundErr)
|
||||
}
|
||||
|
||||
+19
-13
@@ -50,7 +50,13 @@ func (j *XrayTrafficJob) Run() {
|
||||
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()
|
||||
lastOnlineMap, err := j.inboundService.GetClientsLastOnline()
|
||||
if err != nil {
|
||||
@@ -58,8 +64,17 @@ func (j *XrayTrafficJob) Run() {
|
||||
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
|
||||
// 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()
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
// Broadcast traffic update via WebSocket with accumulated values from database
|
||||
trafficUpdate := map[string]any{
|
||||
"traffics": traffics,
|
||||
"clientTraffics": clientTraffics,
|
||||
"onlineClients": onlineClients,
|
||||
"lastOnlineMap": lastOnlineMap,
|
||||
}
|
||||
websocket.BroadcastTraffic(trafficUpdate)
|
||||
|
||||
// Broadcast full inbounds update for real-time UI refresh
|
||||
// The WebSocket hub will automatically check the payload size.
|
||||
// If it exceeds 100MB, it sends a lightweight 'invalidate' signal instead.
|
||||
if updatedInbounds != nil {
|
||||
websocket.BroadcastInbounds(updatedInbounds)
|
||||
}
|
||||
@@ -87,7 +94,6 @@ func (j *XrayTrafficJob) Run() {
|
||||
if updatedOutbounds != nil {
|
||||
websocket.BroadcastOutbounds(updatedOutbounds)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (j *XrayTrafficJob) informTrafficToExternalAPI(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) {
|
||||
|
||||
@@ -0,0 +1,760 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"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")
|
||||
ErrCustomGeoSSRFBlocked = errors.New("custom_geo_ssrf_blocked")
|
||||
ErrCustomGeoPathTraversal = errors.New("custom_geo_path_traversal")
|
||||
)
|
||||
|
||||
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) sanitizeURL(raw string) (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
|
||||
}
|
||||
if err := checkSSRF(context.Background(), u.Hostname()); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Reconstruct URL from parsed components to break taint propagation.
|
||||
clean := &url.URL{
|
||||
Scheme: u.Scheme,
|
||||
Host: u.Host,
|
||||
Path: u.Path,
|
||||
RawPath: u.RawPath,
|
||||
RawQuery: u.RawQuery,
|
||||
Fragment: u.Fragment,
|
||||
}
|
||||
return clean.String(), nil
|
||||
}
|
||||
|
||||
func localDatFileNeedsRepair(path string) bool {
|
||||
safePath, err := sanitizeDestPath(path)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
fi, err := os.Stat(safePath)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
if fi.IsDir() {
|
||||
return true
|
||||
}
|
||||
return fi.Size() < int64(minDatBytes)
|
||||
}
|
||||
|
||||
func CustomGeoLocalFileNeedsRepair(path string) bool {
|
||||
return localDatFileNeedsRepair(path)
|
||||
}
|
||||
|
||||
func isBlockedIP(ip net.IP) bool {
|
||||
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
|
||||
ip.IsLinkLocalMulticast() || ip.IsUnspecified()
|
||||
}
|
||||
|
||||
// checkSSRFDefault validates that the given host does not resolve to a private/internal IP.
|
||||
// It is context-aware so that dial context cancellation/deadlines are respected during DNS resolution.
|
||||
func checkSSRFDefault(ctx context.Context, hostname string) error {
|
||||
ips, err := net.DefaultResolver.LookupIPAddr(ctx, hostname)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: cannot resolve host %s", ErrCustomGeoSSRFBlocked, hostname)
|
||||
}
|
||||
for _, ipAddr := range ips {
|
||||
if isBlockedIP(ipAddr.IP) {
|
||||
return fmt.Errorf("%w: %s resolves to blocked address %s", ErrCustomGeoSSRFBlocked, hostname, ipAddr.IP)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkSSRF is the active SSRF guard. Override in tests to allow localhost test servers.
|
||||
var checkSSRF = checkSSRFDefault
|
||||
|
||||
func ssrfSafeTransport() http.RoundTripper {
|
||||
base, ok := http.DefaultTransport.(*http.Transport)
|
||||
if !ok {
|
||||
base = &http.Transport{}
|
||||
}
|
||||
cloned := base.Clone()
|
||||
cloned.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrCustomGeoSSRFBlocked, err)
|
||||
}
|
||||
if err := checkSSRF(ctx, host); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var dialer net.Dialer
|
||||
return dialer.DialContext(ctx, network, addr)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func probeCustomGeoURLWithGET(rawURL string) error {
|
||||
sanitizedURL, err := (&CustomGeoService{}).sanitizeURL(rawURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client := &http.Client{Timeout: customGeoProbeTimeout, Transport: ssrfSafeTransport()}
|
||||
req, err := http.NewRequest(http.MethodGet, sanitizedURL, 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 {
|
||||
sanitizedURL, err := (&CustomGeoService{}).sanitizeURL(rawURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client := &http.Client{Timeout: customGeoProbeTimeout, Transport: ssrfSafeTransport()}
|
||||
req, err := http.NewRequest(http.MethodHead, sanitizedURL, 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]
|
||||
sanitizedURL, err := s.sanitizeURL(r.Url)
|
||||
if err != nil {
|
||||
logger.Warningf("custom geo startup id=%d: invalid url: %v", r.Id, err)
|
||||
continue
|
||||
}
|
||||
r.Url = sanitizedURL
|
||||
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) {
|
||||
safeDestPath, err := sanitizeDestPath(destPath)
|
||||
if err != nil {
|
||||
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
|
||||
}
|
||||
|
||||
skipped, lm, err := s.downloadToPathOnce(resourceURL, safeDestPath, lastModifiedHeader, false)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
if skipped {
|
||||
if _, statErr := os.Stat(safeDestPath); statErr == nil && !localDatFileNeedsRepair(safeDestPath) {
|
||||
return true, lm, nil
|
||||
}
|
||||
return s.downloadToPathOnce(resourceURL, safeDestPath, lastModifiedHeader, true)
|
||||
}
|
||||
return false, lm, nil
|
||||
}
|
||||
|
||||
// sanitizeDestPath ensures destPath is inside the bin folder, preventing path traversal.
|
||||
// It resolves symlinks to prevent symlink-based escapes.
|
||||
// Returns the cleaned absolute path that is safe to use in file operations.
|
||||
func sanitizeDestPath(destPath string) (string, error) {
|
||||
baseDirAbs, err := filepath.Abs(config.GetBinFolderPath())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: %v", ErrCustomGeoPathTraversal, err)
|
||||
}
|
||||
// Resolve symlinks in base directory to get the real path.
|
||||
if resolved, evalErr := filepath.EvalSymlinks(baseDirAbs); evalErr == nil {
|
||||
baseDirAbs = resolved
|
||||
}
|
||||
destPathAbs, err := filepath.Abs(destPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: %v", ErrCustomGeoPathTraversal, err)
|
||||
}
|
||||
// Resolve symlinks for the parent directory of the destination path.
|
||||
destDir := filepath.Dir(destPathAbs)
|
||||
if resolved, evalErr := filepath.EvalSymlinks(destDir); evalErr == nil {
|
||||
destPathAbs = filepath.Join(resolved, filepath.Base(destPathAbs))
|
||||
}
|
||||
// Verify the resolved path is within the safe base directory using prefix check.
|
||||
safeDirPrefix := baseDirAbs + string(filepath.Separator)
|
||||
if !strings.HasPrefix(destPathAbs, safeDirPrefix) {
|
||||
return "", ErrCustomGeoPathTraversal
|
||||
}
|
||||
return destPathAbs, nil
|
||||
}
|
||||
|
||||
func (s *CustomGeoService) downloadToPathOnce(resourceURL, destPath string, lastModifiedHeader string, forceFull bool) (skipped bool, newLastModified string, err error) {
|
||||
safeDestPath, err := sanitizeDestPath(destPath)
|
||||
if err != nil {
|
||||
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
|
||||
}
|
||||
sanitizedURL, err := s.sanitizeURL(resourceURL)
|
||||
if err != nil {
|
||||
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
|
||||
}
|
||||
|
||||
var req *http.Request
|
||||
req, err = http.NewRequest(http.MethodGet, sanitizedURL, nil)
|
||||
if err != nil {
|
||||
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
|
||||
}
|
||||
|
||||
if !forceFull {
|
||||
if fi, statErr := os.Stat(safeDestPath); statErr == nil && !localDatFileNeedsRepair(safeDestPath) {
|
||||
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, Transport: ssrfSafeTransport()}
|
||||
// lgtm[go/request-forgery]
|
||||
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(safeDestPath, 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(safeDestPath)
|
||||
if err = os.MkdirAll(binDir, 0o755); err != nil {
|
||||
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
|
||||
}
|
||||
|
||||
safeTmpPath, err := sanitizeDestPath(safeDestPath + ".tmp")
|
||||
if err != nil {
|
||||
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
|
||||
}
|
||||
out, err := os.Create(safeTmpPath)
|
||||
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(safeTmpPath)
|
||||
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
|
||||
}
|
||||
if closeErr != nil {
|
||||
_ = os.Remove(safeTmpPath)
|
||||
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, closeErr)
|
||||
}
|
||||
if n < minDatBytes {
|
||||
_ = os.Remove(safeTmpPath)
|
||||
return false, "", fmt.Errorf("%w: file too small", ErrCustomGeoDownload)
|
||||
}
|
||||
|
||||
if err = os.Rename(safeTmpPath, safeDestPath); err != nil {
|
||||
_ = os.Remove(safeTmpPath)
|
||||
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) syncAndSanitizeLocalPath(r *model.CustomGeoResource) error {
|
||||
s.syncLocalPath(r)
|
||||
safePath, err := sanitizeDestPath(r.LocalPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.LocalPath = safePath
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeSafePathIfExists(path string) error {
|
||||
safePath, err := sanitizeDestPath(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(safePath); err == nil {
|
||||
if err := os.Remove(safePath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
sanitizedURL, err := s.sanitizeURL(r.Url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.Url = sanitizedURL
|
||||
var existing int64
|
||||
database.GetDB().Model(&model.CustomGeoResource{}).
|
||||
Where("geo_type = ? AND alias = ?", r.Type, r.Alias).Count(&existing)
|
||||
if existing > 0 {
|
||||
return ErrCustomGeoDuplicateAlias
|
||||
}
|
||||
if err := s.syncAndSanitizeLocalPath(r); err != nil {
|
||||
return err
|
||||
}
|
||||
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 {
|
||||
_ = removeSafePathIfExists(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
|
||||
}
|
||||
sanitizedURL, err := s.sanitizeURL(r.Url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.Url = sanitizedURL
|
||||
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)
|
||||
r.Id = id
|
||||
if err := s.syncAndSanitizeLocalPath(r); err != nil {
|
||||
return err
|
||||
}
|
||||
if oldPath != r.LocalPath && oldPath != "" {
|
||||
if err := removeSafePathIfExists(oldPath); err != nil && !errors.Is(err, ErrCustomGeoPathTraversal) {
|
||||
logger.Warningf("custom geo remove old path %s: %v", oldPath, err)
|
||||
}
|
||||
}
|
||||
_, 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 := sanitizeDestPath(p); err != nil {
|
||||
return displayName, err
|
||||
}
|
||||
if err := database.GetDB().Delete(&model.CustomGeoResource{}, id).Error; err != nil {
|
||||
return displayName, err
|
||||
}
|
||||
if p != "" {
|
||||
if err := removeSafePathIfExists(p); err != nil {
|
||||
logger.Warningf("custom geo delete file %s: %v", p, err)
|
||||
}
|
||||
}
|
||||
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)
|
||||
if err := s.syncAndSanitizeLocalPath(&r); err != nil {
|
||||
return displayName, err
|
||||
}
|
||||
sanitizedURL, sanitizeErr := s.sanitizeURL(r.Url)
|
||||
if sanitizeErr != nil {
|
||||
return displayName, sanitizeErr
|
||||
}
|
||||
skipped, lm, err := s.downloadToPath(sanitizedURL, 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
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||
)
|
||||
|
||||
// disableSSRFCheck disables the SSRF guard for the duration of a test,
|
||||
// allowing httptest servers on localhost. It restores the original on cleanup.
|
||||
func disableSSRFCheck(t *testing.T) {
|
||||
t.Helper()
|
||||
orig := checkSSRF
|
||||
checkSSRF = func(_ context.Context, _ string) error { return nil }
|
||||
t.Cleanup(func() { checkSSRF = orig })
|
||||
}
|
||||
|
||||
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.sanitizeURL(""); !errors.Is(err, ErrCustomGeoURLRequired) {
|
||||
t.Fatal("empty")
|
||||
}
|
||||
if _, err := s.sanitizeURL("ftp://x"); !errors.Is(err, ErrCustomGeoURLScheme) {
|
||||
t.Fatal("ftp")
|
||||
}
|
||||
if sanitized, err := s.sanitizeURL("https://example.com/a.dat"); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if sanitized != "https://example.com/a.dat" {
|
||||
t.Fatalf("unexpected sanitized URL: %s", sanitized)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
disableSSRFCheck(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) {
|
||||
disableSSRFCheck(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) {
|
||||
disableSSRFCheck(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()
|
||||
t.Setenv("XUI_BIN_FOLDER", dir)
|
||||
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) {
|
||||
disableSSRFCheck(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) {
|
||||
disableSSRFCheck(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)
|
||||
}
|
||||
}
|
||||
+101
-11
@@ -270,6 +270,10 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
|
||||
if client.Email == "" {
|
||||
return inbound, false, common.NewError("empty client ID")
|
||||
}
|
||||
case "hysteria":
|
||||
if client.Auth == "" {
|
||||
return inbound, false, common.NewError("empty client ID")
|
||||
}
|
||||
default:
|
||||
if client.ID == "" {
|
||||
return inbound, false, common.NewError("empty client ID")
|
||||
@@ -494,17 +498,23 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
||||
logger.Debug("Old inbound deleted by api:", tag)
|
||||
}
|
||||
if inbound.Enable {
|
||||
inboundJson, err2 := json.MarshalIndent(oldInbound.GenXrayInboundConfig(), "", " ")
|
||||
runtimeInbound, err2 := s.buildRuntimeInboundForAPI(tx, oldInbound)
|
||||
if err2 != nil {
|
||||
logger.Debug("Unable to marshal updated inbound config:", err2)
|
||||
logger.Debug("Unable to prepare runtime inbound config:", err2)
|
||||
needRestart = true
|
||||
} else {
|
||||
err2 = s.xrayApi.AddInbound(inboundJson)
|
||||
if err2 == nil {
|
||||
logger.Debug("Updated inbound added by api:", oldInbound.Tag)
|
||||
} else {
|
||||
logger.Debug("Unable to update inbound by api:", err2)
|
||||
inboundJson, err2 := json.MarshalIndent(runtimeInbound.GenXrayInboundConfig(), "", " ")
|
||||
if err2 != nil {
|
||||
logger.Debug("Unable to marshal updated inbound config:", err2)
|
||||
needRestart = true
|
||||
} else {
|
||||
err2 = s.xrayApi.AddInbound(inboundJson)
|
||||
if err2 == nil {
|
||||
logger.Debug("Updated inbound added by api:", oldInbound.Tag)
|
||||
} else {
|
||||
logger.Debug("Unable to update inbound by api:", err2)
|
||||
needRestart = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -513,6 +523,65 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
||||
return inbound, needRestart, tx.Save(oldInbound).Error
|
||||
}
|
||||
|
||||
func (s *InboundService) buildRuntimeInboundForAPI(tx *gorm.DB, inbound *model.Inbound) (*model.Inbound, error) {
|
||||
if inbound == nil {
|
||||
return nil, fmt.Errorf("inbound is nil")
|
||||
}
|
||||
|
||||
runtimeInbound := *inbound
|
||||
settings := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
clients, ok := settings["clients"].([]any)
|
||||
if !ok {
|
||||
return &runtimeInbound, nil
|
||||
}
|
||||
|
||||
var clientStats []xray.ClientTraffic
|
||||
err := tx.Model(xray.ClientTraffic{}).
|
||||
Where("inbound_id = ?", inbound.Id).
|
||||
Select("email", "enable").
|
||||
Find(&clientStats).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
enableMap := make(map[string]bool, len(clientStats))
|
||||
for _, clientTraffic := range clientStats {
|
||||
enableMap[clientTraffic.Email] = clientTraffic.Enable
|
||||
}
|
||||
|
||||
finalClients := make([]any, 0, len(clients))
|
||||
for _, client := range clients {
|
||||
c, ok := client.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
email, _ := c["email"].(string)
|
||||
if enable, exists := enableMap[email]; exists && !enable {
|
||||
continue
|
||||
}
|
||||
|
||||
if manualEnable, ok := c["enable"].(bool); ok && !manualEnable {
|
||||
continue
|
||||
}
|
||||
|
||||
finalClients = append(finalClients, c)
|
||||
}
|
||||
|
||||
settings["clients"] = finalClients
|
||||
modifiedSettings, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
runtimeInbound.Settings = string(modifiedSettings)
|
||||
|
||||
return &runtimeInbound, nil
|
||||
}
|
||||
|
||||
func (s *InboundService) updateClientTraffics(tx *gorm.DB, oldInbound *model.Inbound, newInbound *model.Inbound) error {
|
||||
oldClients, err := s.GetClients(oldInbound)
|
||||
if err != nil {
|
||||
@@ -606,6 +675,10 @@ func (s *InboundService) AddInboundClient(data *model.Inbound) (bool, error) {
|
||||
if client.Email == "" {
|
||||
return false, common.NewError("empty client ID")
|
||||
}
|
||||
case "hysteria":
|
||||
if client.Auth == "" {
|
||||
return false, common.NewError("empty client ID")
|
||||
}
|
||||
default:
|
||||
if client.ID == "" {
|
||||
return false, common.NewError("empty client ID")
|
||||
@@ -655,6 +728,7 @@ func (s *InboundService) AddInboundClient(data *model.Inbound) (bool, error) {
|
||||
err1 := s.xrayApi.AddUser(string(oldInbound.Protocol), oldInbound.Tag, map[string]any{
|
||||
"email": client.Email,
|
||||
"id": client.ID,
|
||||
"auth": client.Auth,
|
||||
"security": client.Security,
|
||||
"flow": client.Flow,
|
||||
"password": client.Password,
|
||||
@@ -690,11 +764,13 @@ func (s *InboundService) DelInboundClient(inboundId int, clientId string) (bool,
|
||||
|
||||
email := ""
|
||||
client_key := "id"
|
||||
if oldInbound.Protocol == "trojan" {
|
||||
switch oldInbound.Protocol {
|
||||
case "trojan":
|
||||
client_key = "password"
|
||||
}
|
||||
if oldInbound.Protocol == "shadowsocks" {
|
||||
case "shadowsocks":
|
||||
client_key = "email"
|
||||
case "hysteria":
|
||||
client_key = "auth"
|
||||
}
|
||||
|
||||
interfaceClients := settings["clients"].([]any)
|
||||
@@ -801,6 +877,9 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin
|
||||
case "shadowsocks":
|
||||
oldClientId = oldClient.Email
|
||||
newClientId = clients[0].Email
|
||||
case "hysteria":
|
||||
oldClientId = oldClient.Auth
|
||||
newClientId = clients[0].Auth
|
||||
default:
|
||||
oldClientId = oldClient.ID
|
||||
newClientId = clients[0].ID
|
||||
@@ -921,6 +1000,7 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin
|
||||
"id": clients[0].ID,
|
||||
"security": clients[0].Security,
|
||||
"flow": clients[0].Flow,
|
||||
"auth": clients[0].Auth,
|
||||
"password": clients[0].Password,
|
||||
"cipher": cipher,
|
||||
})
|
||||
@@ -1813,6 +1893,7 @@ func (s *InboundService) ResetClientTraffic(id int, clientEmail string) (bool, e
|
||||
err1 := s.xrayApi.AddUser(string(inbound.Protocol), inbound.Tag, map[string]any{
|
||||
"email": client.Email,
|
||||
"id": client.ID,
|
||||
"auth": client.Auth,
|
||||
"security": client.Security,
|
||||
"flow": client.Flow,
|
||||
"password": client.Password,
|
||||
@@ -1891,6 +1972,16 @@ func (s *InboundService) ResetAllTraffics() error {
|
||||
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) {
|
||||
db := database.GetDB()
|
||||
tx := db.Begin()
|
||||
@@ -2032,7 +2123,6 @@ func (s *InboundService) GetClientTrafficByEmail(email string) (traffic *xray.Cl
|
||||
return nil, err
|
||||
}
|
||||
if t != nil && client != nil {
|
||||
t.Enable = client.Enable
|
||||
t.UUID = client.ID
|
||||
t.SubId = client.SubID
|
||||
return t, nil
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v2/util/common"
|
||||
)
|
||||
|
||||
type NordService struct {
|
||||
SettingService
|
||||
}
|
||||
|
||||
var nordHTTPClient = &http.Client{Timeout: 15 * time.Second}
|
||||
|
||||
// maxResponseSize limits the maximum size of NordVPN API responses (10 MB).
|
||||
const maxResponseSize = 10 << 20
|
||||
|
||||
func (s *NordService) GetCountries() (string, error) {
|
||||
resp, err := nordHTTPClient.Get("https://api.nordvpn.com/v1/countries")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
func (s *NordService) GetServers(countryId string) (string, error) {
|
||||
// Validate countryId is numeric to prevent URL injection
|
||||
for _, c := range countryId {
|
||||
if c < '0' || c > '9' {
|
||||
return "", common.NewError("invalid country ID")
|
||||
}
|
||||
}
|
||||
url := fmt.Sprintf("https://api.nordvpn.com/v2/servers?limit=0&filters[servers_technologies][id]=35&filters[country_id]=%s", countryId)
|
||||
resp, err := nordHTTPClient.Get(url)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
servers, ok := data["servers"].([]any)
|
||||
if !ok {
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
var filtered []any
|
||||
for _, s := range servers {
|
||||
if server, ok := s.(map[string]any); ok {
|
||||
if load, ok := server["load"].(float64); ok && load > 7 {
|
||||
filtered = append(filtered, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
data["servers"] = filtered
|
||||
|
||||
result, _ := json.Marshal(data)
|
||||
return string(result), nil
|
||||
}
|
||||
|
||||
func (s *NordService) SetKey(privateKey string) (string, error) {
|
||||
if privateKey == "" {
|
||||
return "", common.NewError("private key cannot be empty")
|
||||
}
|
||||
nordData := map[string]string{
|
||||
"private_key": privateKey,
|
||||
"token": "",
|
||||
}
|
||||
data, _ := json.Marshal(nordData)
|
||||
err := s.SettingService.SetNord(string(data))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func (s *NordService) GetCredentials(token string) (string, error) {
|
||||
url := "https://api.nordvpn.com/v1/users/services/credentials"
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.SetBasicAuth("token", token)
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", common.NewErrorf("NordVPN API error: %s", resp.Status)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var creds map[string]any
|
||||
if err := json.Unmarshal(body, &creds); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
privateKey, ok := creds["nordlynx_private_key"].(string)
|
||||
if !ok || privateKey == "" {
|
||||
return "", common.NewError("failed to retrieve NordLynx private key")
|
||||
}
|
||||
|
||||
nordData := map[string]string{
|
||||
"private_key": privateKey,
|
||||
"token": token,
|
||||
}
|
||||
data, _ := json.Marshal(nordData)
|
||||
err = s.SettingService.SetNord(string(data))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func (s *NordService) GetNordData() (string, error) {
|
||||
return s.SettingService.GetNord()
|
||||
}
|
||||
|
||||
func (s *NordService) DelNordData() error {
|
||||
return s.SettingService.SetNord("")
|
||||
}
|
||||
+57
-1
@@ -34,6 +34,8 @@ import (
|
||||
"github.com/shirou/gopsutil/v4/load"
|
||||
"github.com/shirou/gopsutil/v4/mem"
|
||||
"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.
|
||||
@@ -567,7 +569,7 @@ func (s *ServerService) GetXrayVersions() ([]string, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
if major > 26 || (major == 26 && minor > 2) || (major == 26 && minor == 2 && patch >= 6) {
|
||||
if major > 26 || (major == 26 && minor > 4) || (major == 26 && minor == 4 && patch >= 17) {
|
||||
versions = append(versions, release.TagName)
|
||||
}
|
||||
}
|
||||
@@ -1055,6 +1057,48 @@ func (s *ServerService) IsValidGeofileName(filename string) bool {
|
||||
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 {
|
||||
type geofileEntry struct {
|
||||
URL string
|
||||
@@ -1146,12 +1190,22 @@ func (s *ServerService) UpdateGeofile(fileName string) error {
|
||||
|
||||
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 == "" {
|
||||
// Download all geofiles
|
||||
for _, entry := range geofileAllowlist {
|
||||
destPath := filepath.Join(config.GetBinFolderPath(), entry.FileName)
|
||||
if err := downloadFile(entry.URL, destPath); err != nil {
|
||||
errorMessages = append(errorMessages, fmt.Sprintf("Error downloading Geofile '%s': %v", entry.FileName, err))
|
||||
} else {
|
||||
normalizeIfGeosite(destPath, entry.FileName)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -1159,6 +1213,8 @@ func (s *ServerService) UpdateGeofile(fileName string) error {
|
||||
destPath := filepath.Join(config.GetBinFolderPath(), entry.FileName)
|
||||
if err := downloadFile(entry.URL, destPath); err != nil {
|
||||
errorMessages = append(errorMessages, fmt.Sprintf("Error downloading Geofile '%s': %v", entry.FileName, err))
|
||||
} else {
|
||||
normalizeIfGeosite(destPath, entry.FileName)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+51
-15
@@ -71,12 +71,16 @@ var defaultValueMap = map[string]string{
|
||||
"subURI": "",
|
||||
"subJsonPath": "/json/",
|
||||
"subJsonURI": "",
|
||||
"subClashEnable": "true",
|
||||
"subClashPath": "/clash/",
|
||||
"subClashURI": "",
|
||||
"subJsonFragment": "",
|
||||
"subJsonNoises": "",
|
||||
"subJsonMux": "",
|
||||
"subJsonRules": "",
|
||||
"datepicker": "gregorian",
|
||||
"warp": "",
|
||||
"nord": "",
|
||||
"externalTrafficInformEnable": "false",
|
||||
"externalTrafficInformURI": "",
|
||||
"xrayOutboundTestUrl": "https://www.google.com/generate_204",
|
||||
@@ -555,6 +559,18 @@ func (s *SettingService) GetSubJsonURI() (string, error) {
|
||||
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) {
|
||||
return s.getString("subJsonFragment")
|
||||
}
|
||||
@@ -583,6 +599,14 @@ func (s *SettingService) SetWarp(data string) error {
|
||||
return s.setString("warp", data)
|
||||
}
|
||||
|
||||
func (s *SettingService) GetNord() (string, error) {
|
||||
return s.getString("nord")
|
||||
}
|
||||
|
||||
func (s *SettingService) SetNord(data string) error {
|
||||
return s.setString("nord", data)
|
||||
}
|
||||
|
||||
func (s *SettingService) GetExternalTrafficInformEnable() (bool, error) {
|
||||
return s.getBool("externalTrafficInformEnable")
|
||||
}
|
||||
@@ -743,20 +767,22 @@ func extractHostname(host string) string {
|
||||
func (s *SettingService) GetDefaultSettings(host string) (any, error) {
|
||||
type settingFunc func() (any, error)
|
||||
settings := map[string]settingFunc{
|
||||
"expireDiff": func() (any, error) { return s.GetExpireDiff() },
|
||||
"trafficDiff": func() (any, error) { return s.GetTrafficDiff() },
|
||||
"pageSize": func() (any, error) { return s.GetPageSize() },
|
||||
"defaultCert": func() (any, error) { return s.GetCertFile() },
|
||||
"defaultKey": func() (any, error) { return s.GetKeyFile() },
|
||||
"tgBotEnable": func() (any, error) { return s.GetTgbotEnabled() },
|
||||
"subEnable": func() (any, error) { return s.GetSubEnable() },
|
||||
"subJsonEnable": func() (any, error) { return s.GetSubJsonEnable() },
|
||||
"subTitle": func() (any, error) { return s.GetSubTitle() },
|
||||
"subURI": func() (any, error) { return s.GetSubURI() },
|
||||
"subJsonURI": func() (any, error) { return s.GetSubJsonURI() },
|
||||
"remarkModel": func() (any, error) { return s.GetRemarkModel() },
|
||||
"datepicker": func() (any, error) { return s.GetDatepicker() },
|
||||
"ipLimitEnable": func() (any, error) { return s.GetIpLimitEnable() },
|
||||
"expireDiff": func() (any, error) { return s.GetExpireDiff() },
|
||||
"trafficDiff": func() (any, error) { return s.GetTrafficDiff() },
|
||||
"pageSize": func() (any, error) { return s.GetPageSize() },
|
||||
"defaultCert": func() (any, error) { return s.GetCertFile() },
|
||||
"defaultKey": func() (any, error) { return s.GetKeyFile() },
|
||||
"tgBotEnable": func() (any, error) { return s.GetTgbotEnabled() },
|
||||
"subEnable": func() (any, error) { return s.GetSubEnable() },
|
||||
"subJsonEnable": func() (any, error) { return s.GetSubJsonEnable() },
|
||||
"subClashEnable": func() (any, error) { return s.GetSubClashEnable() },
|
||||
"subTitle": func() (any, error) { return s.GetSubTitle() },
|
||||
"subURI": func() (any, error) { return s.GetSubURI() },
|
||||
"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() },
|
||||
"ipLimitEnable": func() (any, error) { return s.GetIpLimitEnable() },
|
||||
}
|
||||
|
||||
result := make(map[string]any)
|
||||
@@ -776,12 +802,19 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
|
||||
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 := ""
|
||||
subTitle, _ := s.GetSubTitle()
|
||||
subPort, _ := s.GetSubPort()
|
||||
subPath, _ := s.GetSubPath()
|
||||
subJsonPath, _ := s.GetSubJsonPath()
|
||||
subClashPath, _ := s.GetSubClashPath()
|
||||
subDomain, _ := s.GetSubDomain()
|
||||
subKeyFile, _ := s.GetSubKeyFile()
|
||||
subCertFile, _ := s.GetSubCertFile()
|
||||
@@ -811,6 +844,9 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
|
||||
if subJsonEnable && result["subJsonURI"].(string) == "" {
|
||||
result["subJsonURI"] = subURI + subJsonPath
|
||||
}
|
||||
if subClashEnable && result["subClashURI"].(string) == "" {
|
||||
result["subClashURI"] = subURI + subClashPath
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
|
||||
+38
-64
@@ -1926,6 +1926,8 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
|
||||
} else {
|
||||
t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
|
||||
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":
|
||||
client_Enable = true
|
||||
@@ -1936,6 +1938,8 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
|
||||
} else {
|
||||
t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
|
||||
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":
|
||||
t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
|
||||
@@ -3302,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.
|
||||
func (t *Tgbot) addClient(chatId int64, msg string, messageID ...int) {
|
||||
inbound, err := t.inboundService.GetInbound(receiver_inbound_ID)
|
||||
@@ -3312,91 +3337,40 @@ func (t *Tgbot) addClient(chatId int64, msg string, messageID ...int) {
|
||||
|
||||
protocol := inbound.Protocol
|
||||
|
||||
var protocolRows [][]telego.InlineKeyboardButton
|
||||
switch protocol {
|
||||
case model.VMESS, model.VLESS:
|
||||
inlineKeyboard := tu.InlineKeyboard(
|
||||
protocolRows = [][]telego.InlineKeyboardButton{
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"),
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_id")).WithCallbackData("add_client_ch_default_id"),
|
||||
),
|
||||
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:
|
||||
inlineKeyboard := tu.InlineKeyboard(
|
||||
protocolRows = [][]telego.InlineKeyboardButton{
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"),
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_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:
|
||||
inlineKeyboard := tu.InlineKeyboard(
|
||||
protocolRows = [][]telego.InlineKeyboardButton{
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"),
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_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.
|
||||
|
||||
+9
-11
@@ -33,7 +33,7 @@ func (s *UserService) GetFirstUser() (*model.User, error) {
|
||||
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()
|
||||
|
||||
user := &model.User{}
|
||||
@@ -43,17 +43,16 @@ func (s *UserService) CheckUser(username string, password string, twoFactorCode
|
||||
First(user).
|
||||
Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil
|
||||
return nil, errors.New("invalid credentials")
|
||||
} else if err != nil {
|
||||
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) {
|
||||
ldapEnabled, _ := s.settingService.GetLdapEnable()
|
||||
if !ldapEnabled {
|
||||
return nil
|
||||
return nil, errors.New("invalid credentials")
|
||||
}
|
||||
|
||||
host, _ := s.settingService.GetLdapHost()
|
||||
@@ -77,15 +76,14 @@ func (s *UserService) CheckUser(username string, password string, twoFactorCode
|
||||
}
|
||||
ok, err := ldaputil.AuthenticateUser(cfg, username, password)
|
||||
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()
|
||||
if err != nil {
|
||||
logger.Warning("check two factor err:", err)
|
||||
return nil
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if twoFactorEnable {
|
||||
@@ -93,15 +91,15 @@ func (s *UserService) CheckUser(username string, password string, twoFactorCode
|
||||
|
||||
if err != nil {
|
||||
logger.Warning("check two factor token err:", err)
|
||||
return nil
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
+24
-20
@@ -118,36 +118,40 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
|
||||
json.Unmarshal([]byte(inbound.Settings), &settings)
|
||||
clients, ok := settings["clients"].([]any)
|
||||
if ok {
|
||||
// check users active or not
|
||||
// Fast O(N) lookup map for client traffic enablement
|
||||
clientStats := inbound.ClientStats
|
||||
enableMap := make(map[string]bool, len(clientStats))
|
||||
for _, clientTraffic := range clientStats {
|
||||
indexDecrease := 0
|
||||
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"])
|
||||
}
|
||||
}
|
||||
}
|
||||
enableMap[clientTraffic.Email] = clientTraffic.Enable
|
||||
}
|
||||
|
||||
// clear client config for additional parameters
|
||||
// filter and clean clients
|
||||
var final_clients []any
|
||||
for _, client := range clients {
|
||||
c := client.(map[string]any)
|
||||
if c["enable"] != nil {
|
||||
if enable, ok := c["enable"].(bool); ok && !enable {
|
||||
continue
|
||||
}
|
||||
c, ok := client.(map[string]any)
|
||||
if !ok {
|
||||
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 {
|
||||
if key != "email" && key != "id" && key != "password" && key != "flow" && key != "method" {
|
||||
if key != "email" && key != "id" && key != "password" && key != "flow" && key != "method" && key != "auth" {
|
||||
delete(c, key)
|
||||
}
|
||||
if c["flow"] == "xtls-rprx-vision-udp443" {
|
||||
if flow, ok := c["flow"].(string); ok && flow == "xtls-rprx-vision-udp443" {
|
||||
c["flow"] = "xtls-rprx-vision"
|
||||
}
|
||||
}
|
||||
|
||||
+5
-14
@@ -14,7 +14,6 @@ import (
|
||||
|
||||
const (
|
||||
loginUserKey = "LOGIN_USER"
|
||||
defaultPath = "/"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -31,18 +30,6 @@ func SetLoginUser(c *gin.Context, user *model.User) {
|
||||
s.Set(loginUserKey, *user)
|
||||
}
|
||||
|
||||
// SetMaxAge configures the session cookie maximum age in seconds.
|
||||
// This controls how long the session remains valid before requiring re-authentication.
|
||||
func SetMaxAge(c *gin.Context, maxAge int) {
|
||||
s := sessions.Default(c)
|
||||
s.Options(sessions.Options{
|
||||
Path: defaultPath,
|
||||
MaxAge: maxAge,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
// GetLoginUser retrieves the authenticated user from the session.
|
||||
// Returns nil if no user is logged in or if the session data is invalid.
|
||||
func GetLoginUser(c *gin.Context) *model.User {
|
||||
@@ -71,8 +58,12 @@ func IsLogin(c *gin.Context) bool {
|
||||
func ClearSession(c *gin.Context) {
|
||||
s := sessions.Default(c)
|
||||
s.Clear()
|
||||
cookiePath := c.GetString("base_path")
|
||||
if cookiePath == "" {
|
||||
cookiePath = "/"
|
||||
}
|
||||
s.Options(sessions.Options{
|
||||
Path: defaultPath,
|
||||
Path: cookiePath,
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
"confirm" = "تأكيد"
|
||||
"cancel" = "إلغاء"
|
||||
"close" = "إغلاق"
|
||||
"save" = "حفظ"
|
||||
"logout" = "تسجيل خروج"
|
||||
"create" = "إنشاء"
|
||||
"update" = "تحديث"
|
||||
"copy" = "نسخ"
|
||||
@@ -164,6 +166,47 @@
|
||||
"readDatabaseError" = "حدث خطأ أثناء قراءة قاعدة البيانات"
|
||||
"getDatabaseError" = "حدث خطأ أثناء استرجاع قاعدة البيانات"
|
||||
"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]
|
||||
"allTimeTraffic" = "إجمالي حركة المرور"
|
||||
@@ -271,6 +314,7 @@
|
||||
"daily" = "يومياً"
|
||||
"weekly" = "أسبوعياً"
|
||||
"monthly" = "شهرياً"
|
||||
"hourly" = "كل ساعة"
|
||||
|
||||
[pages.inbounds.toasts]
|
||||
"obtain" = "تم الحصول عليه"
|
||||
@@ -454,6 +498,8 @@
|
||||
"ipv4RoutingDesc" = "الخيارات دي هتوجه الترافيك بناءً على وجهة معينة عبر IPv4."
|
||||
"warpRouting" = "توجيه WARP"
|
||||
"warpRoutingDesc" = "الخيارات دي هتوجه الترافيك بناءً على وجهة معينة عبر WARP."
|
||||
"nordRouting" = "توجيه NordVPN"
|
||||
"nordRoutingDesc" = "الخيارات دي هتوجه الترافيك بناءً على وجهة معينة عبر NordVPN."
|
||||
"Template" = "قالب إعدادات Xray المتقدم"
|
||||
"TemplateDesc" = "ملف إعدادات Xray النهائي هيتولد بناءً على القالب ده."
|
||||
"FreedomStrategy" = "استراتيجية بروتوكول الحرية"
|
||||
@@ -531,6 +577,14 @@
|
||||
"testSuccess" = "الاختبار ناجح"
|
||||
"testFailed" = "فشل الاختبار"
|
||||
"testError" = "فشل اختبار المخرج"
|
||||
"nordvpn" = "NordVPN"
|
||||
"accessToken" = "رمز الوصول"
|
||||
"country" = "الدولة"
|
||||
"server" = "الخادم"
|
||||
"city" = "المدينة"
|
||||
"allCities" = "كل المدن"
|
||||
"privateKey" = "المفتاح الخاص"
|
||||
"load" = "الحمل"
|
||||
|
||||
[pages.xray.balancer]
|
||||
"addBalancer" = "أضف موازن تحميل"
|
||||
@@ -663,6 +717,7 @@
|
||||
"userSaved" = "✅ حفظت بيانات مستخدم Telegram."
|
||||
"loginSuccess" = "✅ تسجيل الدخول للبانل تم بنجاح.\r\n"
|
||||
"loginFailed" = "❗️فشل محاولة تسجيل الدخول للبانل.\r\n"
|
||||
"2faFailed" = "فشل 2FA"
|
||||
"report" = "🕰 التقارير المجدولة: {{ .RunTime }}\r\n"
|
||||
"datetime" = "⏰ التاريخ والوقت: {{ .DateTime }}\r\n"
|
||||
"hostname" = "💻 السيرفر: {{ .Hostname }}\r\n"
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
"confirm" = "Confirm"
|
||||
"cancel" = "Cancel"
|
||||
"close" = "Close"
|
||||
"save" = "Save"
|
||||
"logout" = "Log Out"
|
||||
"create" = "Create"
|
||||
"update" = "Update"
|
||||
"copy" = "Copy"
|
||||
@@ -150,6 +152,47 @@
|
||||
"geofilesUpdateDialogDesc" = "This will update all geofiles."
|
||||
"geofilesUpdateAll" = "Update all"
|
||||
"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"
|
||||
"logs" = "Logs"
|
||||
"config" = "Config"
|
||||
@@ -271,6 +314,7 @@
|
||||
"daily" = "Daily"
|
||||
"weekly" = "Weekly"
|
||||
"monthly" = "Monthly"
|
||||
"hourly" = "Hourly"
|
||||
|
||||
[pages.inbounds.toasts]
|
||||
"obtain" = "Obtain"
|
||||
@@ -454,6 +498,8 @@
|
||||
"ipv4RoutingDesc" = "These options will route traffic based on a specific destination via IPv4."
|
||||
"warpRouting" = "WARP Routing"
|
||||
"warpRoutingDesc" = "These options will route traffic based on a specific destination via WARP."
|
||||
"nordRouting" = "NordVPN Routing"
|
||||
"nordRoutingDesc" = "These options will route traffic based on a specific destination via NordVPN."
|
||||
"Template" = "Advanced Xray Configuration Template"
|
||||
"TemplateDesc" = "The final Xray config file will be generated based on this template."
|
||||
"FreedomStrategy" = "Freedom Protocol Strategy"
|
||||
@@ -531,6 +577,14 @@
|
||||
"testSuccess" = "Test successful"
|
||||
"testFailed" = "Test failed"
|
||||
"testError" = "Failed to test outbound"
|
||||
"nordvpn" = "NordVPN"
|
||||
"accessToken" = "Access Token"
|
||||
"country" = "Country"
|
||||
"server" = "Server"
|
||||
"city" = "City"
|
||||
"allCities" = "All Cities"
|
||||
"privateKey" = "Private Key"
|
||||
"load" = "Load"
|
||||
|
||||
[pages.xray.balancer]
|
||||
"addBalancer" = "Add Balancer"
|
||||
@@ -663,6 +717,7 @@
|
||||
"userSaved" = "✅ Telegram User saved."
|
||||
"loginSuccess" = "✅ Logged in to the panel successfully.\r\n"
|
||||
"loginFailed" = "❗️Login attempt to the panel failed.\r\n"
|
||||
"2faFailed" = "2FA Failed"
|
||||
"report" = "🕰 Scheduled Reports: {{ .RunTime }}\r\n"
|
||||
"datetime" = "⏰ Date&Time: {{ .DateTime }}\r\n"
|
||||
"hostname" = "💻 Host: {{ .Hostname }}\r\n"
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
"confirm" = "Confirmar"
|
||||
"cancel" = "Cancelar"
|
||||
"close" = "Cerrar"
|
||||
"save" = "Guardar"
|
||||
"logout" = "Cerrar Sesión"
|
||||
"create" = "Crear"
|
||||
"update" = "Actualizar"
|
||||
"copy" = "Copiar"
|
||||
@@ -164,6 +166,47 @@
|
||||
"readDatabaseError" = "Ocurrió un error al leer 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"
|
||||
"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]
|
||||
"allTimeTraffic" = "Tráfico Total"
|
||||
@@ -271,6 +314,7 @@
|
||||
"daily" = "Diariamente"
|
||||
"weekly" = "Semanalmente"
|
||||
"monthly" = "Mensualmente"
|
||||
"hourly" = "Cada hora"
|
||||
|
||||
[pages.inbounds.toasts]
|
||||
"obtain" = "Recibir"
|
||||
@@ -454,6 +498,8 @@
|
||||
"ipv4RoutingDesc" = "Estas opciones solo enrutarán a los dominios objetivo a través de IPv4."
|
||||
"warpRouting" = "Enrutamiento WARP"
|
||||
"warpRoutingDesc" = "Precaución: Antes de usar estas opciones, instale WARP en modo de proxy socks5 en su servidor siguiendo los pasos en el GitHub del panel. WARP enrutará el tráfico a los sitios web a través de los servidores de Cloudflare."
|
||||
"nordRouting" = "Enrutamiento NordVPN"
|
||||
"nordRoutingDesc" = "Estas opciones enrutarán el tráfico basado en un destino específico a través de NordVPN."
|
||||
"Template" = "Plantilla de Configuración de Xray"
|
||||
"TemplateDesc" = "Genera el archivo de configuración final de Xray basado en esta plantilla."
|
||||
"FreedomStrategy" = "Configurar Estrategia para el Protocolo Freedom"
|
||||
@@ -531,6 +577,14 @@
|
||||
"testSuccess" = "Prueba exitosa"
|
||||
"testFailed" = "Prueba fallida"
|
||||
"testError" = "Error al probar la salida"
|
||||
"nordvpn" = "NordVPN"
|
||||
"accessToken" = "Token de acceso"
|
||||
"country" = "País"
|
||||
"server" = "Servidor"
|
||||
"city" = "Ciudad"
|
||||
"allCities" = "Todas las ciudades"
|
||||
"privateKey" = "Clave privada"
|
||||
"load" = "Carga"
|
||||
|
||||
[pages.xray.balancer]
|
||||
"addBalancer" = "Agregar equilibrador"
|
||||
@@ -663,6 +717,7 @@
|
||||
"userSaved" = "✅ Usuario de Telegram guardado."
|
||||
"loginSuccess" = "✅ Has iniciado sesión en el panel con éxito.\r\n"
|
||||
"loginFailed" = "❗️ Falló el inicio de sesión en el panel.\r\n"
|
||||
"2faFailed" = "Error de 2FA"
|
||||
"report" = "🕰 Informes programados: {{ .RunTime }}\r\n"
|
||||
"datetime" = "⏰ Fecha y Hora: {{ .DateTime }}\r\n"
|
||||
"hostname" = "💻 Nombre del Host: {{ .Hostname }}\r\n"
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
"confirm" = "تایید"
|
||||
"cancel" = "انصراف"
|
||||
"close" = "بستن"
|
||||
"save" = "ذخیره"
|
||||
"logout" = "خروج"
|
||||
"create" = "ایجاد"
|
||||
"update" = "بهروزرسانی"
|
||||
"copy" = "کپی"
|
||||
@@ -164,6 +166,47 @@
|
||||
"readDatabaseError" = "خطا در خواندن پایگاه داده"
|
||||
"getDatabaseError" = "خطا در دریافت پایگاه داده"
|
||||
"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]
|
||||
"allTimeTraffic" = "کل ترافیک"
|
||||
@@ -271,6 +314,7 @@
|
||||
"daily" = "روزانه"
|
||||
"weekly" = "هفتگی"
|
||||
"monthly" = "ماهانه"
|
||||
"hourly" = "هر ساعت"
|
||||
|
||||
[pages.inbounds.toasts]
|
||||
"obtain" = "فراهمسازی"
|
||||
@@ -454,6 +498,8 @@
|
||||
"ipv4RoutingDesc" = "این گزینهها ترافیک را از طریق آیپی نسخه4 سرور، به مقصد هدایت میکند"
|
||||
"warpRouting" = "WARP مسیریابی"
|
||||
"warpRoutingDesc" = "این گزینهها ترافیک را از طریق وارپ کلادفلر به مقصد هدایت میکند"
|
||||
"nordRouting" = "مسیریابی NordVPN"
|
||||
"nordRoutingDesc" = "این گزینهها ترافیک را بر اساس مقصد خاص از طریق NordVPN مسیریابی میکنند."
|
||||
"Template" = "پیکربندی پیشرفته الگو ایکسری"
|
||||
"TemplateDesc" = "فایل پیکربندی نهایی ایکسری بر اساس این الگو ایجاد میشود"
|
||||
"FreedomStrategy" = "Freedom استراتژی پروتکل"
|
||||
@@ -531,6 +577,14 @@
|
||||
"testSuccess" = "تست موفقیتآمیز"
|
||||
"testFailed" = "تست ناموفق"
|
||||
"testError" = "خطا در تست خروجی"
|
||||
"nordvpn" = "NordVPN"
|
||||
"accessToken" = "توکن دسترسی"
|
||||
"country" = "کشور"
|
||||
"server" = "سرور"
|
||||
"privateKey" = "کلید خصوصی"
|
||||
"city" = "شهر"
|
||||
"allCities" = "همه شهرها"
|
||||
"load" = "فشار سرور"
|
||||
|
||||
[pages.xray.balancer]
|
||||
"addBalancer" = "افزودن بالانسر"
|
||||
@@ -663,6 +717,7 @@
|
||||
"userSaved" = "✅ کاربر تلگرام ذخیره شد."
|
||||
"loginSuccess" = "✅ با موفقیت به پنل وارد شدید.\r\n"
|
||||
"loginFailed" = "❗️ ورود به پنل ناموفقبود \r\n"
|
||||
"2faFailed" = "خطای 2FA"
|
||||
"report" = "🕰 گزارشاتزمانبندیشده: {{ .RunTime }}\r\n"
|
||||
"datetime" = "⏰ تاریخوزمان: {{ .DateTime }}\r\n"
|
||||
"hostname" = "💻 ناممیزبان: {{ .Hostname }}\r\n"
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
"confirm" = "Konfirmasi"
|
||||
"cancel" = "Batal"
|
||||
"close" = "Tutup"
|
||||
"save" = "Simpan"
|
||||
"logout" = "Keluar"
|
||||
"create" = "Buat"
|
||||
"update" = "Perbarui"
|
||||
"copy" = "Salin"
|
||||
@@ -164,6 +166,47 @@
|
||||
"readDatabaseError" = "Terjadi kesalahan saat membaca database"
|
||||
"getDatabaseError" = "Terjadi kesalahan saat mengambil database"
|
||||
"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]
|
||||
"allTimeTraffic" = "Total Lalu Lintas"
|
||||
@@ -271,6 +314,7 @@
|
||||
"daily" = "Harian"
|
||||
"weekly" = "Mingguan"
|
||||
"monthly" = "Bulanan"
|
||||
"hourly" = "Setiap jam"
|
||||
|
||||
[pages.inbounds.toasts]
|
||||
"obtain" = "Dapatkan"
|
||||
@@ -454,6 +498,8 @@
|
||||
"ipv4RoutingDesc" = "Opsi ini akan mengalihkan lalu lintas berdasarkan tujuan tertentu melalui IPv4."
|
||||
"warpRouting" = "Perutean WARP"
|
||||
"warpRoutingDesc" = "Opsi ini akan mengalihkan lalu lintas berdasarkan tujuan tertentu melalui WARP."
|
||||
"nordRouting" = "Routing NordVPN"
|
||||
"nordRoutingDesc" = "Opsi ini akan mengalihkan lalu lintas berdasarkan tujuan tertentu melalui NordVPN."
|
||||
"Template" = "Template Konfigurasi Xray Lanjutan"
|
||||
"TemplateDesc" = "File konfigurasi Xray akhir akan dibuat berdasarkan template ini."
|
||||
"FreedomStrategy" = "Strategi Protokol Freedom"
|
||||
@@ -531,6 +577,14 @@
|
||||
"testSuccess" = "Tes berhasil"
|
||||
"testFailed" = "Tes gagal"
|
||||
"testError" = "Gagal menguji outbound"
|
||||
"nordvpn" = "NordVPN"
|
||||
"accessToken" = "Token Akses"
|
||||
"country" = "Negara"
|
||||
"server" = "Server"
|
||||
"city" = "Kota"
|
||||
"allCities" = "Semua Kota"
|
||||
"privateKey" = "Kunci Privat"
|
||||
"load" = "Beban"
|
||||
|
||||
[pages.xray.balancer]
|
||||
"addBalancer" = "Tambahkan Penyeimbang"
|
||||
@@ -663,6 +717,7 @@
|
||||
"userSaved" = "✅ Pengguna Telegram tersimpan."
|
||||
"loginSuccess" = "✅ Berhasil masuk ke panel.\r\n"
|
||||
"loginFailed" = "❗️ Gagal masuk ke panel.\r\n"
|
||||
"2faFailed" = "2FA Gagal"
|
||||
"report" = "🕰 Laporan Terjadwal: {{ .RunTime }}\r\n"
|
||||
"datetime" = "⏰ Tanggal & Waktu: {{ .DateTime }}\r\n"
|
||||
"hostname" = "💻 Host: {{ .Hostname }}\r\n"
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
"confirm" = "確認"
|
||||
"cancel" = "キャンセル"
|
||||
"close" = "閉じる"
|
||||
"save" = "保存"
|
||||
"logout" = "ログアウト"
|
||||
"create" = "作成"
|
||||
"update" = "更新"
|
||||
"copy" = "コピー"
|
||||
@@ -164,6 +166,47 @@
|
||||
"readDatabaseError" = "データベースの読み取り中にエラーが発生しました"
|
||||
"getDatabaseError" = "データベースの取得中にエラーが発生しました"
|
||||
"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]
|
||||
"allTimeTraffic" = "総トラフィック"
|
||||
@@ -271,6 +314,7 @@
|
||||
"daily" = "毎日"
|
||||
"weekly" = "毎週"
|
||||
"monthly" = "毎月"
|
||||
"hourly" = "毎時"
|
||||
|
||||
[pages.inbounds.toasts]
|
||||
"obtain" = "取得"
|
||||
@@ -454,6 +498,8 @@
|
||||
"ipv4RoutingDesc" = "このオプションはIPv4のみを介してターゲットドメインへルーティングします"
|
||||
"warpRouting" = "WARP ルーティング"
|
||||
"warpRoutingDesc" = "注意:これらのオプションを使用する前に、パネルのGitHubの手順に従って、サーバーにsocks5プロキシモードでWARPをインストールしてください。WARPはCloudflareサーバー経由でトラフィックをウェブサイトにルーティングします。"
|
||||
"nordRouting" = "NordVPN ルーティング"
|
||||
"nordRoutingDesc" = "これらのオプションはNordVPN経由で特定の宛先にトラフィックをルーティングします。"
|
||||
"Template" = "高度なXray設定テンプレート"
|
||||
"TemplateDesc" = "最終的なXray設定ファイルはこのテンプレートに基づいて生成されます"
|
||||
"FreedomStrategy" = "Freedom プロトコル戦略"
|
||||
@@ -531,6 +577,14 @@
|
||||
"testSuccess" = "テスト成功"
|
||||
"testFailed" = "テスト失敗"
|
||||
"testError" = "アウトバウンドのテストに失敗しました"
|
||||
"nordvpn" = "NordVPN"
|
||||
"accessToken" = "アクセストークン"
|
||||
"country" = "国"
|
||||
"server" = "サーバー"
|
||||
"city" = "都市"
|
||||
"allCities" = "すべての都市"
|
||||
"privateKey" = "秘密鍵"
|
||||
"load" = "負荷"
|
||||
|
||||
[pages.xray.balancer]
|
||||
"addBalancer" = "負荷分散追加"
|
||||
@@ -663,6 +717,7 @@
|
||||
"userSaved" = "✅ Telegramユーザーが保存されました。"
|
||||
"loginSuccess" = "✅ パネルに正常にログインしました。\r\n"
|
||||
"loginFailed" = "❗️ パネルのログインに失敗しました。\r\n"
|
||||
"2faFailed" = "2FAエラー"
|
||||
"report" = "🕰 定期報告:{{ .RunTime }}\r\n"
|
||||
"datetime" = "⏰ 日時:{{ .DateTime }}\r\n"
|
||||
"hostname" = "💻 ホスト名:{{ .Hostname }}\r\n"
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
"confirm" = "Confirmar"
|
||||
"cancel" = "Cancelar"
|
||||
"close" = "Fechar"
|
||||
"save" = "Salvar"
|
||||
"logout" = "Sair"
|
||||
"create" = "Criar"
|
||||
"update" = "Atualizar"
|
||||
"copy" = "Copiar"
|
||||
@@ -164,6 +166,47 @@
|
||||
"readDatabaseError" = "Ocorreu um erro ao ler 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"
|
||||
"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]
|
||||
"allTimeTraffic" = "Tráfego Total"
|
||||
@@ -271,6 +314,7 @@
|
||||
"daily" = "Diariamente"
|
||||
"weekly" = "Semanalmente"
|
||||
"monthly" = "Mensalmente"
|
||||
"hourly" = "A cada hora"
|
||||
|
||||
[pages.inbounds.toasts]
|
||||
"obtain" = "Obter"
|
||||
@@ -454,6 +498,8 @@
|
||||
"ipv4RoutingDesc" = "Essas opções roteam o tráfego para um destino específico via IPv4."
|
||||
"warpRouting" = "Roteamento WARP"
|
||||
"warpRoutingDesc" = "Essas opções roteam o tráfego para um destino específico via WARP."
|
||||
"nordRouting" = "Roteamento NordVPN"
|
||||
"nordRoutingDesc" = "Essas opções roteiam o tráfego para um destino específico via NordVPN."
|
||||
"Template" = "Modelo de Configuração Avançada do Xray"
|
||||
"TemplateDesc" = "O arquivo final de configuração do Xray será gerado com base neste modelo."
|
||||
"FreedomStrategy" = "Estratégia do Protocolo Freedom"
|
||||
@@ -531,6 +577,14 @@
|
||||
"testSuccess" = "Teste bem-sucedido"
|
||||
"testFailed" = "Teste falhou"
|
||||
"testError" = "Falha ao testar saída"
|
||||
"nordvpn" = "NordVPN"
|
||||
"accessToken" = "Token de Acesso"
|
||||
"country" = "País"
|
||||
"server" = "Servidor"
|
||||
"city" = "Cidade"
|
||||
"allCities" = "Todas as Cidades"
|
||||
"privateKey" = "Chave Privada"
|
||||
"load" = "Carga"
|
||||
|
||||
[pages.xray.balancer]
|
||||
"addBalancer" = "Adicionar Balanceador"
|
||||
@@ -663,6 +717,7 @@
|
||||
"userSaved" = "✅ Usuário do Telegram salvo."
|
||||
"loginSuccess" = "✅ Conectado ao painel com sucesso.\r\n"
|
||||
"loginFailed" = "❗️Tentativa de login no painel falhou.\r\n"
|
||||
"2faFailed" = "Falha no 2FA"
|
||||
"report" = "🕰 Relatórios agendados: {{ .RunTime }}\r\n"
|
||||
"datetime" = "⏰ Data&Hora: {{ .DateTime }}\r\n"
|
||||
"hostname" = "💻 Host: {{ .Hostname }}\r\n"
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
"confirm" = "Подтвердить"
|
||||
"cancel" = "Отмена"
|
||||
"close" = "Закрыть"
|
||||
"save" = "Сохранить"
|
||||
"logout" = "Выход"
|
||||
"create" = "Создать"
|
||||
"update" = "Обновить"
|
||||
"copy" = "Копировать"
|
||||
@@ -149,7 +151,48 @@
|
||||
"geofileUpdateDialogDesc" = "Это обновит файл #filename#."
|
||||
"geofilesUpdateDialogDesc" = "Это обновит все геофайлы."
|
||||
"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" = "Установка в процессе. Не обновляйте страницу"
|
||||
"logs" = "Журнал"
|
||||
"config" = "Конфигурация"
|
||||
@@ -271,6 +314,7 @@
|
||||
"daily" = "Ежедневно"
|
||||
"weekly" = "Еженедельно"
|
||||
"monthly" = "Ежемесячно"
|
||||
"hourly" = "Ежечасно"
|
||||
|
||||
[pages.inbounds.toasts]
|
||||
"obtain" = "Получить"
|
||||
@@ -453,7 +497,9 @@
|
||||
"ipv4Routing" = "Правила IPv4"
|
||||
"ipv4RoutingDesc" = "Эти параметры позволят клиентам маршрутизироваться к целевым доменам только через IPv4"
|
||||
"warpRouting" = "Правила WARP"
|
||||
"warpRoutingDesc" = " Эти опции будут направлять трафик в зависимости от конкретного пункта назначения через WARP."
|
||||
"warpRoutingDesc" = " Эти опции будут направлять трафик в зависимости от конкретного пункта назначения через WARP."
|
||||
"nordRouting" = "Маршрутизация NordVPN"
|
||||
"nordRoutingDesc" = "Эти опции будут направлять трафик в зависимости от конкретного пункта назначения через NordVPN."
|
||||
"Template" = "Шаблон конфигурации Xray"
|
||||
"TemplateDesc" = "На основе шаблона создаётся конфигурационный файл Xray."
|
||||
"FreedomStrategy" = "Настройка стратегии протокола Freedom"
|
||||
@@ -531,6 +577,14 @@
|
||||
"testSuccess" = "Тест успешен"
|
||||
"testFailed" = "Тест не пройден"
|
||||
"testError" = "Не удалось протестировать исходящее подключение"
|
||||
"nordvpn" = "NordVPN"
|
||||
"accessToken" = "Токен доступа"
|
||||
"country" = "Страна"
|
||||
"server" = "Сервер"
|
||||
"city" = "Город"
|
||||
"allCities" = "Все города"
|
||||
"privateKey" = "Приватный ключ"
|
||||
"load" = "Нагрузка"
|
||||
|
||||
[pages.xray.balancer]
|
||||
"addBalancer" = "Создать балансировщик"
|
||||
@@ -663,6 +717,7 @@
|
||||
"userSaved" = "✅ Пользователь Telegram сохранен."
|
||||
"loginSuccess" = "✅ Успешный вход в панель.\r\n"
|
||||
"loginFailed" = "❗️ Ошибка входа в панель.\r\n"
|
||||
"2faFailed" = "Ошибка 2FA"
|
||||
"report" = "🕰 Запланированные отчеты: {{ .RunTime }}\r\n"
|
||||
"datetime" = "⏰ Дата и время: {{ .DateTime }}\r\n"
|
||||
"hostname" = "💻 Имя хоста: {{ .Hostname }}\r\n"
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
"confirm" = "Onayla"
|
||||
"cancel" = "İptal"
|
||||
"close" = "Kapat"
|
||||
"save" = "Kaydet"
|
||||
"logout" = "Çıkış Yap"
|
||||
"create" = "Oluştur"
|
||||
"update" = "Güncelle"
|
||||
"copy" = "Kopyala"
|
||||
@@ -164,6 +166,47 @@
|
||||
"readDatabaseError" = "Veritabanı okunurken 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"
|
||||
"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]
|
||||
"allTimeTraffic" = "Toplam Trafik"
|
||||
@@ -271,6 +314,7 @@
|
||||
"daily" = "Günlük"
|
||||
"weekly" = "Haftalık"
|
||||
"monthly" = "Aylık"
|
||||
"hourly" = "Saatlik"
|
||||
|
||||
[pages.inbounds.toasts]
|
||||
"obtain" = "Elde Et"
|
||||
@@ -454,6 +498,8 @@
|
||||
"ipv4RoutingDesc" = "Bu seçenekler belirli bir varış yerine IPv4 üzerinden trafiği yönlendirir."
|
||||
"warpRouting" = "WARP Yönlendirme"
|
||||
"warpRoutingDesc" = "Bu seçenekler belirli bir varış yerine WARP üzerinden trafiği yönlendirir."
|
||||
"nordRouting" = "NordVPN Yönlendirme"
|
||||
"nordRoutingDesc" = "Bu seçenekler belirli bir varış yerine NordVPN üzerinden trafiği yönlendirir."
|
||||
"Template" = "Gelişmiş Xray Yapılandırma Şablonu"
|
||||
"TemplateDesc" = "Nihai Xray yapılandırma dosyası bu şablona göre oluşturulacaktır."
|
||||
"FreedomStrategy" = "Freedom Protokol Stratejisi"
|
||||
@@ -531,6 +577,14 @@
|
||||
"testSuccess" = "Test başarılı"
|
||||
"testFailed" = "Test başarısız"
|
||||
"testError" = "Giden test edilemedi"
|
||||
"nordvpn" = "NordVPN"
|
||||
"accessToken" = "Erişim Jetonu"
|
||||
"country" = "Ülke"
|
||||
"server" = "Sunucu"
|
||||
"city" = "Şehir"
|
||||
"allCities" = "Tüm Şehirler"
|
||||
"privateKey" = "Özel Anahtar"
|
||||
"load" = "Yük"
|
||||
|
||||
[pages.xray.balancer]
|
||||
"addBalancer" = "Dengeleyici Ekle"
|
||||
@@ -663,6 +717,7 @@
|
||||
"userSaved" = "✅ Telegram Kullanıcısı kaydedildi."
|
||||
"loginSuccess" = "✅ Panele başarıyla giriş yapıldı.\r\n"
|
||||
"loginFailed" = "❗️Panele giriş denemesi başarısız oldu.\r\n"
|
||||
"2faFailed" = "2FA Hatası"
|
||||
"report" = "🕰 Planlanmış Raporlar: {{ .RunTime }}\r\n"
|
||||
"datetime" = "⏰ Tarih&Zaman: {{ .DateTime }}\r\n"
|
||||
"hostname" = "💻 Sunucu: {{ .Hostname }}\r\n"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user