mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-04 12:15:02 +03:00
Compare commits
128 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b5c239f98 | |||
| 03393c9f52 | |||
| b56db67759 | |||
| 6d05702d00 | |||
| 9791b05a4e | |||
| 0aca2d3b3d | |||
| 8529f4f0cf | |||
| abc5cf3439 | |||
| a7e7788e29 | |||
| 8620344925 | |||
| 47e229e323 | |||
| 4521beab7c | |||
| a62c637632 | |||
| 35609b7b13 | |||
| a4b1b3d06d | |||
| 5f7c7c5f3d | |||
| 6bcaf61c44 | |||
| ff25072690 | |||
| 530c1597b8 | |||
| c8e16d8c41 | |||
| 17f67ef3a5 | |||
| eb4791a1cd | |||
| 71ac920436 | |||
| e6d0c33937 | |||
| eef2d311f4 | |||
| 772d2b6de4 | |||
| 8f30d14716 | |||
| 9611c9def6 | |||
| 292eb992f4 | |||
| 814e6ad69c | |||
| 0a38624ba7 | |||
| b86473df02 | |||
| 15be803da9 | |||
| c79b45e512 | |||
| 86a8eb16b4 | |||
| 0fd0389d5c | |||
| 2983ac3f8e | |||
| 975d6d1bad | |||
| ab7a7f7c6b | |||
| 733f44ef0f | |||
| faec3ca038 | |||
| df163854bd | |||
| 1af795fad8 | |||
| 085cb8c216 | |||
| 2a9ba2badc | |||
| 53fb4fe8f9 | |||
| 8d512d55e5 | |||
| 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 | |||
| 52fdf5d429 | |||
| 34d8885075 | |||
| 5740996436 | |||
| 874aae8080 | |||
| 842fae18d7 | |||
| ccd223aeea | |||
| 96b8fe472c | |||
| 59b695ba83 | |||
| 159b85f979 | |||
| 3ec5b3589f | |||
| 2b1d3e7347 | |||
| 37f0880f8f | |||
| 5b796672e9 | |||
| 3fa0da38c9 | |||
| 8eb1225734 | |||
| e5c0fe3edf | |||
| f4057989f5 | |||
| 84013b0b3f | |||
| 511adffc5b | |||
| fc6344b840 | |||
| b3555ce1b8 | |||
| c2f409c3c4 | |||
| 0994f8756f | |||
| 4779939424 | |||
| 4a455aa532 | |||
| 25f64738e4 | |||
| 5bb87fd3d4 | |||
| 491e3f9f8b | |||
| d8fb09faae | |||
| f87c68ea68 | |||
| 687e8cf1ba | |||
| 03f04194f2 | |||
| 248700a8a3 | |||
| ff128a7275 | |||
| e8d2973be7 |
@@ -0,0 +1,155 @@
|
|||||||
|
# 3X-UI Development Guide
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
3X-UI is a web-based control panel for managing Xray-core servers. It's a Go application using Gin web framework with embedded static assets and SQLite database. The panel manages VPN/proxy inbounds, monitors traffic, and provides Telegram bot integration.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Core Components
|
||||||
|
- **main.go**: Entry point that initializes database, web server, and subscription server. Handles graceful shutdown via SIGHUP/SIGTERM signals
|
||||||
|
- **web/**: Primary web server with Gin router, HTML templates, and static assets embedded via `//go:embed`
|
||||||
|
- **xray/**: Xray-core process management and API communication for traffic monitoring
|
||||||
|
- **database/**: GORM-based SQLite database with models in `database/model/`
|
||||||
|
- **sub/**: Subscription server running alongside main web server (separate port)
|
||||||
|
- **web/service/**: Business logic layer containing InboundService, SettingService, TgBot, etc.
|
||||||
|
- **web/controller/**: HTTP handlers using Gin context (`*gin.Context`)
|
||||||
|
- **web/job/**: Cron-based background jobs for traffic monitoring, CPU checks, LDAP sync
|
||||||
|
|
||||||
|
### Key Architectural Patterns
|
||||||
|
1. **Embedded Resources**: All web assets (HTML, CSS, JS, translations) are embedded at compile time using `embed.FS`:
|
||||||
|
- `web/assets` → `assetsFS`
|
||||||
|
- `web/html` → `htmlFS`
|
||||||
|
- `web/translation` → `i18nFS`
|
||||||
|
|
||||||
|
2. **Dual Server Design**: Main web panel + subscription server run concurrently, managed by `web/global` package
|
||||||
|
|
||||||
|
3. **Xray Integration**: Panel generates `config.json` for Xray binary, communicates via gRPC API for real-time traffic stats
|
||||||
|
|
||||||
|
4. **Signal-Based Restart**: SIGHUP triggers graceful restart. **Critical**: Always call `service.StopBot()` before restart to prevent Telegram bot 409 conflicts
|
||||||
|
|
||||||
|
5. **Database Seeders**: Uses `HistoryOfSeeders` model to track one-time migrations (e.g., password bcrypt migration)
|
||||||
|
|
||||||
|
## Development Workflows
|
||||||
|
|
||||||
|
### Building & Running
|
||||||
|
```bash
|
||||||
|
# Build (creates bin/3x-ui.exe)
|
||||||
|
go run tasks.json → "go: build" task
|
||||||
|
|
||||||
|
# Run with debug logging
|
||||||
|
XUI_DEBUG=true go run ./main.go
|
||||||
|
# Or use task: "go: run"
|
||||||
|
|
||||||
|
# Test
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Command-Line Operations
|
||||||
|
The main.go accepts flags for admin tasks:
|
||||||
|
- `-reset` - Reset all panel settings to defaults
|
||||||
|
- `-show` - Display current settings (port, paths)
|
||||||
|
- Use these by running the binary directly, not via web interface
|
||||||
|
|
||||||
|
### Database Management
|
||||||
|
- DB path: Configured via `config.GetDBPath()`, typically `/etc/x-ui/x-ui.db`
|
||||||
|
- Models: Located in `database/model/model.go` - Auto-migrated on startup
|
||||||
|
- Seeders: Use `HistoryOfSeeders` to prevent re-running migrations
|
||||||
|
- Default credentials: admin/admin (hashed with bcrypt)
|
||||||
|
|
||||||
|
### Telegram Bot Development
|
||||||
|
- Bot instance in `web/service/tgbot.go` (3700+ lines)
|
||||||
|
- Uses `telego` library with long polling
|
||||||
|
- **Critical Pattern**: Must call `service.StopBot()` before any server restart to prevent 409 bot conflicts
|
||||||
|
- Bot handlers use `telegohandler.BotHandler` for routing
|
||||||
|
- i18n via embedded `i18nFS` passed to bot startup
|
||||||
|
|
||||||
|
## Code Conventions
|
||||||
|
|
||||||
|
### Service Layer Pattern
|
||||||
|
Services inject dependencies (like xray.XrayAPI) and operate on GORM models:
|
||||||
|
```go
|
||||||
|
type InboundService struct {
|
||||||
|
xrayApi xray.XrayAPI
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InboundService) GetInbounds(userId int) ([]*model.Inbound, error) {
|
||||||
|
// Business logic here
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Controller Pattern
|
||||||
|
Controllers use Gin context and inherit from BaseController:
|
||||||
|
```go
|
||||||
|
func (a *InboundController) getInbounds(c *gin.Context) {
|
||||||
|
// Use I18nWeb(c, "key") for translations
|
||||||
|
// Check auth via checkLogin middleware
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration Management
|
||||||
|
- Environment vars: `XUI_DEBUG`, `XUI_LOG_LEVEL`, `XUI_MAIN_FOLDER`
|
||||||
|
- Config embedded files: `config/version`, `config/name`
|
||||||
|
- Use `config.GetLogLevel()`, `config.GetDBPath()` helpers
|
||||||
|
|
||||||
|
### Internationalization
|
||||||
|
- Translation files: `web/translation/translate.*.toml`
|
||||||
|
- Access via `I18nWeb(c, "pages.login.loginAgain")` in controllers
|
||||||
|
- Use `locale.I18nType` enum (Web, Api, etc.)
|
||||||
|
|
||||||
|
## External Dependencies & Integration
|
||||||
|
|
||||||
|
### Xray-core
|
||||||
|
- Binary management: Download platform-specific binary (`xray-{os}-{arch}`) to bin folder
|
||||||
|
- Config generation: Panel creates `config.json` dynamically from inbound/outbound settings
|
||||||
|
- Process control: Start/stop via `xray/process.go`
|
||||||
|
- gRPC API: Real-time stats via `xray/api.go` using `google.golang.org/grpc`
|
||||||
|
|
||||||
|
### Critical External Paths
|
||||||
|
- Xray binary: `{bin_folder}/xray-{os}-{arch}`
|
||||||
|
- Xray config: `{bin_folder}/config.json`
|
||||||
|
- GeoIP/GeoSite: `{bin_folder}/geoip.dat`, `geosite.dat`
|
||||||
|
- Logs: `{log_folder}/3xipl.log`, `3xipl-banned.log`
|
||||||
|
|
||||||
|
### Job Scheduling
|
||||||
|
Uses `robfig/cron/v3` for periodic tasks:
|
||||||
|
- Traffic monitoring: `xray_traffic_job.go`
|
||||||
|
- CPU alerts: `check_cpu_usage.go`
|
||||||
|
- IP tracking: `check_client_ip_job.go`
|
||||||
|
- LDAP sync: `ldap_sync_job.go`
|
||||||
|
|
||||||
|
Jobs registered in `web/web.go` during server initialization
|
||||||
|
|
||||||
|
## Deployment & Scripts
|
||||||
|
|
||||||
|
### Installation Script Pattern
|
||||||
|
Both `install.sh` and `x-ui.sh` follow these patterns:
|
||||||
|
- Multi-distro support via `$release` variable (ubuntu, debian, centos, arch, etc.)
|
||||||
|
- Port detection with `is_port_in_use()` using ss/netstat/lsof
|
||||||
|
- Systemd service management with distro-specific unit files (`.service.debian`, `.service.arch`, `.service.rhel`)
|
||||||
|
|
||||||
|
### Docker Build
|
||||||
|
Multi-stage Dockerfile:
|
||||||
|
1. **Builder**: CGO-enabled build, runs `DockerInit.sh` to download Xray binary
|
||||||
|
2. **Final**: Alpine-based with fail2ban pre-configured
|
||||||
|
|
||||||
|
### Key File Locations (Production)
|
||||||
|
- Binary: `/usr/local/x-ui/`
|
||||||
|
- Database: `/etc/x-ui/x-ui.db`
|
||||||
|
- Logs: `/var/log/x-ui/`
|
||||||
|
- Service: `/etc/systemd/system/x-ui.service.*`
|
||||||
|
|
||||||
|
## Testing & Debugging
|
||||||
|
- Set `XUI_DEBUG=true` for detailed logging
|
||||||
|
- Check Xray process: `x-ui.sh` script provides menu for status/logs
|
||||||
|
- Database inspection: Direct SQLite access to x-ui.db
|
||||||
|
- Traffic debugging: Check `3xipl.log` for IP limit tracking
|
||||||
|
- Telegram bot: Logs show bot initialization and command handling
|
||||||
|
|
||||||
|
## Common Gotchas
|
||||||
|
1. **Bot Restart**: Always stop Telegram bot before server restart to avoid 409 conflict
|
||||||
|
2. **Embedded Assets**: Changes to HTML/CSS require recompilation (not hot-reload)
|
||||||
|
3. **Password Migration**: Seeder system tracks bcrypt migration - check `HistoryOfSeeders` table
|
||||||
|
4. **Port Binding**: Subscription server uses different port from main panel
|
||||||
|
5. **Xray Binary**: Must match OS/arch exactly - managed by installer scripts
|
||||||
|
6. **Session Management**: Uses `gin-contrib/sessions` with cookie store
|
||||||
|
7. **IP Limitation**: Implements "last IP wins" - when client exceeds LimitIP, oldest connections are automatically disconnected via Xray API to allow newest IPs
|
||||||
@@ -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,31 @@
|
|||||||
|
name: Cleanup Caches
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 3 * * *" # every day
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
cleanup:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
actions: write
|
||||||
|
steps:
|
||||||
|
- name: Delete caches older than 1 day
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
CUTOFF_DATE=$(date -d "1 days ago" -Ins --utc | sed 's/+0000/Z/')
|
||||||
|
echo "Deleting caches older than: $CUTOFF_DATE"
|
||||||
|
|
||||||
|
CACHE_IDS=$(gh api --paginate repos/${{ github.repository }}/actions/caches \
|
||||||
|
--jq ".actions_caches[] | select(.last_accessed_at < \"$CUTOFF_DATE\") | .id" 2>/dev/null)
|
||||||
|
|
||||||
|
if [ -z "$CACHE_IDS" ]; then
|
||||||
|
echo "No old caches found to delete."
|
||||||
|
else
|
||||||
|
echo "$CACHE_IDS" | while read CACHE_ID; do
|
||||||
|
echo "Deleting cache: $CACHE_ID"
|
||||||
|
gh api -X DELETE repos/${{ github.repository }}/actions/caches/$CACHE_ID
|
||||||
|
done
|
||||||
|
echo "Old caches deleted successfully."
|
||||||
|
fi
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
name: "CodeQL Advanced"
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags-ignore:
|
||||||
|
- "v*"
|
||||||
|
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
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
submodules: true
|
submodules: true
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
hsanaeii/3x-ui
|
hsanaeii/3x-ui
|
||||||
@@ -32,28 +32,28 @@ jobs:
|
|||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v4
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
with:
|
with:
|
||||||
install: true
|
install: true
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||||
|
|
||||||
- name: Login to GHCR
|
- name: Login to GHCR
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
|
|||||||
@@ -2,27 +2,64 @@ name: Release 3X-UI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
release:
|
|
||||||
types: [published]
|
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- "**"
|
||||||
tags:
|
tags:
|
||||||
- "v*.*.*"
|
- "v*.*.*"
|
||||||
paths:
|
paths:
|
||||||
- '**.js'
|
- "**.js"
|
||||||
- '**.css'
|
- "**.css"
|
||||||
- '**.html'
|
- "**.html"
|
||||||
- '**.sh'
|
- "**.sh"
|
||||||
- '**.go'
|
- "**.go"
|
||||||
- 'go.mod'
|
- "go.mod"
|
||||||
- 'go.sum'
|
- "go.sum"
|
||||||
- 'x-ui.service.debian'
|
- "x-ui.service.debian"
|
||||||
- 'x-ui.service.arch'
|
- "x-ui.service.arch"
|
||||||
- 'x-ui.service.rhel'
|
- "x-ui.service.rhel"
|
||||||
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
analyze:
|
||||||
|
name: Analyze Go code
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Check formatting
|
||||||
|
run: |
|
||||||
|
unformatted=$(gofmt -l .)
|
||||||
|
if [ -n "$unformatted" ]; then
|
||||||
|
echo "These files are not gofmt-formatted:"
|
||||||
|
echo "$unformatted"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Run go vet
|
||||||
|
run: go vet ./...
|
||||||
|
|
||||||
|
- name: Run staticcheck
|
||||||
|
uses: dominikh/staticcheck-action@v1
|
||||||
|
with:
|
||||||
|
version: "latest"
|
||||||
|
install-go: false
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: go test -race -shuffle=on ./...
|
||||||
|
|
||||||
build:
|
build:
|
||||||
|
needs: analyze
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
strategy:
|
strategy:
|
||||||
@@ -38,7 +75,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
@@ -89,7 +126,7 @@ jobs:
|
|||||||
cd x-ui/bin
|
cd x-ui/bin
|
||||||
|
|
||||||
# Download dependencies
|
# Download dependencies
|
||||||
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.1.31/"
|
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.4.25/"
|
||||||
if [ "${{ matrix.platform }}" == "amd64" ]; then
|
if [ "${{ matrix.platform }}" == "amd64" ]; then
|
||||||
wget -q ${Xray_URL}Xray-linux-64.zip
|
wget -q ${Xray_URL}Xray-linux-64.zip
|
||||||
unzip Xray-linux-64.zip
|
unzip Xray-linux-64.zip
|
||||||
@@ -133,19 +170,17 @@ jobs:
|
|||||||
run: tar -zcvf x-ui-linux-${{ matrix.platform }}.tar.gz x-ui
|
run: tar -zcvf x-ui-linux-${{ matrix.platform }}.tar.gz x-ui
|
||||||
|
|
||||||
- name: Upload files to Artifacts
|
- name: Upload files to Artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: x-ui-linux-${{ matrix.platform }}
|
name: x-ui-linux-${{ matrix.platform }}
|
||||||
path: ./x-ui-linux-${{ matrix.platform }}.tar.gz
|
path: ./x-ui-linux-${{ matrix.platform }}.tar.gz
|
||||||
|
|
||||||
- name: Upload files to GH release
|
- name: Upload files to GH release
|
||||||
uses: svenstaro/upload-release-action@v2
|
uses: svenstaro/upload-release-action@v2
|
||||||
if: |
|
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||||
(github.event_name == 'release' && github.event.action == 'published') ||
|
|
||||||
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/'))
|
|
||||||
with:
|
with:
|
||||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
tag: ${{ github.ref }}
|
tag: ${{ github.ref_name }}
|
||||||
file: x-ui-linux-${{ matrix.platform }}.tar.gz
|
file: x-ui-linux-${{ matrix.platform }}.tar.gz
|
||||||
asset_name: x-ui-linux-${{ matrix.platform }}.tar.gz
|
asset_name: x-ui-linux-${{ matrix.platform }}.tar.gz
|
||||||
overwrite: true
|
overwrite: true
|
||||||
@@ -156,6 +191,7 @@ jobs:
|
|||||||
# =================================
|
# =================================
|
||||||
build-windows:
|
build-windows:
|
||||||
name: Build for Windows
|
name: Build for Windows
|
||||||
|
needs: analyze
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
strategy:
|
strategy:
|
||||||
@@ -165,7 +201,7 @@ jobs:
|
|||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
@@ -173,21 +209,42 @@ jobs:
|
|||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
check-latest: true
|
check-latest: true
|
||||||
|
|
||||||
- name: Build 3X-UI for Windows
|
- name: Install MSYS2
|
||||||
shell: pwsh
|
uses: msys2/setup-msys2@v2
|
||||||
|
with:
|
||||||
|
msystem: MINGW64
|
||||||
|
update: true
|
||||||
|
install: >-
|
||||||
|
mingw-w64-x86_64-gcc
|
||||||
|
mingw-w64-x86_64-sqlite3
|
||||||
|
mingw-w64-x86_64-pkg-config
|
||||||
|
|
||||||
|
- name: Build 3X-UI for Windows (CGO)
|
||||||
|
shell: msys2 {0}
|
||||||
run: |
|
run: |
|
||||||
$env:CGO_ENABLED="1"
|
export PATH="/c/hostedtoolcache/windows/go/$(ls /c/hostedtoolcache/windows/go | sort -V | tail -n1)/x64/bin:$PATH"
|
||||||
$env:GOOS="windows"
|
|
||||||
$env:GOARCH="amd64"
|
export CGO_ENABLED=1
|
||||||
|
export GOOS=windows
|
||||||
|
export GOARCH=amd64
|
||||||
|
export CC=x86_64-w64-mingw32-gcc
|
||||||
|
|
||||||
|
which go
|
||||||
|
go version
|
||||||
|
gcc --version
|
||||||
|
|
||||||
go build -ldflags "-w -s" -o xui-release.exe -v main.go
|
go build -ldflags "-w -s" -o xui-release.exe -v main.go
|
||||||
|
|
||||||
|
- name: Copy and download resources
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
mkdir x-ui
|
mkdir x-ui
|
||||||
Copy-Item xui-release.exe x-ui\
|
Copy-Item xui-release.exe x-ui\x-ui.exe
|
||||||
mkdir x-ui\bin
|
mkdir x-ui\bin
|
||||||
cd x-ui\bin
|
cd x-ui\bin
|
||||||
|
|
||||||
# Download Xray for Windows
|
# Download Xray for Windows
|
||||||
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.1.31/"
|
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.4.25/"
|
||||||
Invoke-WebRequest -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
|
Invoke-WebRequest -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
|
||||||
Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
|
Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
|
||||||
Remove-Item "Xray-windows-64.zip"
|
Remove-Item "Xray-windows-64.zip"
|
||||||
@@ -209,19 +266,17 @@ jobs:
|
|||||||
Compress-Archive -Path .\x-ui -DestinationPath "x-ui-windows-amd64.zip"
|
Compress-Archive -Path .\x-ui -DestinationPath "x-ui-windows-amd64.zip"
|
||||||
|
|
||||||
- name: Upload files to Artifacts
|
- name: Upload files to Artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: x-ui-windows-amd64
|
name: x-ui-windows-amd64
|
||||||
path: ./x-ui-windows-amd64.zip
|
path: ./x-ui-windows-amd64.zip
|
||||||
|
|
||||||
- name: Upload files to GH release
|
- name: Upload files to GH release
|
||||||
uses: svenstaro/upload-release-action@v2
|
uses: svenstaro/upload-release-action@v2
|
||||||
if: |
|
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||||
(github.event_name == 'release' && github.event.action == 'published') ||
|
|
||||||
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/'))
|
|
||||||
with:
|
with:
|
||||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
tag: ${{ github.ref }}
|
tag: ${{ github.ref_name }}
|
||||||
file: x-ui-windows-amd64.zip
|
file: x-ui-windows-amd64.zip
|
||||||
asset_name: x-ui-windows-amd64.zip
|
asset_name: x-ui-windows-amd64.zip
|
||||||
overwrite: true
|
overwrite: true
|
||||||
|
|||||||
+1
-1
@@ -27,7 +27,7 @@ case $1 in
|
|||||||
esac
|
esac
|
||||||
mkdir -p build/bin
|
mkdir -p build/bin
|
||||||
cd build/bin
|
cd build/bin
|
||||||
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.1.31/Xray-linux-${ARCH}.zip"
|
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.4.25/Xray-linux-${ARCH}.zip"
|
||||||
unzip "Xray-linux-${ARCH}.zip"
|
unzip "Xray-linux-${ARCH}.zip"
|
||||||
rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
|
rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
|
||||||
mv xray "xray-linux-${FNAME}"
|
mv xray "xray-linux-${FNAME}"
|
||||||
|
|||||||
+3
-2
@@ -1,7 +1,7 @@
|
|||||||
# ========================================================
|
# ========================================================
|
||||||
# Stage: Builder
|
# Stage: Builder
|
||||||
# ========================================================
|
# ========================================================
|
||||||
FROM golang:1.25-alpine AS builder
|
FROM golang:1.26-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
|
|
||||||
@@ -30,7 +30,8 @@ RUN apk add --no-cache --update \
|
|||||||
tzdata \
|
tzdata \
|
||||||
fail2ban \
|
fail2ban \
|
||||||
bash \
|
bash \
|
||||||
curl
|
curl \
|
||||||
|
openssl
|
||||||
|
|
||||||
COPY --from=builder /app/build/ /app/
|
COPY --from=builder /app/build/ /app/
|
||||||
COPY --from=builder /app/DockerEntrypoint.sh /app/
|
COPY --from=builder /app/DockerEntrypoint.sh /app/
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
2.8.9
|
2.9.3
|
||||||
+2
-2
@@ -38,6 +38,7 @@ func initModels() error {
|
|||||||
&model.InboundClientIps{},
|
&model.InboundClientIps{},
|
||||||
&xray.ClientTraffic{},
|
&xray.ClientTraffic{},
|
||||||
&model.HistoryOfSeeders{},
|
&model.HistoryOfSeeders{},
|
||||||
|
&model.CustomGeoResource{},
|
||||||
}
|
}
|
||||||
for _, model := range models {
|
for _, model := range models {
|
||||||
if err := db.AutoMigrate(model); err != nil {
|
if err := db.AutoMigrate(model); err != nil {
|
||||||
@@ -175,9 +176,8 @@ func GetDB() *gorm.DB {
|
|||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsNotFound checks if the given error is a GORM record not found error.
|
|
||||||
func IsNotFound(err error) bool {
|
func IsNotFound(err error) bool {
|
||||||
return err == gorm.ErrRecordNotFound
|
return errors.Is(err, gorm.ErrRecordNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsSQLiteDB checks if the given file is a valid SQLite database by reading its signature.
|
// IsSQLiteDB checks if the given file is a valid SQLite database by reading its signature.
|
||||||
|
|||||||
+29
-3
@@ -21,8 +21,21 @@ const (
|
|||||||
Shadowsocks Protocol = "shadowsocks"
|
Shadowsocks Protocol = "shadowsocks"
|
||||||
Mixed Protocol = "mixed"
|
Mixed Protocol = "mixed"
|
||||||
WireGuard Protocol = "wireguard"
|
WireGuard Protocol = "wireguard"
|
||||||
|
// UI stores Hysteria v1 and v2 both as "hysteria" and uses
|
||||||
|
// settings.version to discriminate. Imports from outside the panel
|
||||||
|
// can carry the literal "hysteria2" string, so IsHysteria below
|
||||||
|
// accepts both.
|
||||||
|
Hysteria Protocol = "hysteria"
|
||||||
|
Hysteria2 Protocol = "hysteria2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// IsHysteria returns true for both "hysteria" and "hysteria2".
|
||||||
|
// Use instead of a bare ==model.Hysteria check: a v2 inbound stored
|
||||||
|
// with the literal v2 string would otherwise fall through (#4081).
|
||||||
|
func IsHysteria(p Protocol) bool {
|
||||||
|
return p == Hysteria || p == Hysteria2
|
||||||
|
}
|
||||||
|
|
||||||
// User represents a user account in the 3x-ui panel.
|
// User represents a user account in the 3x-ui panel.
|
||||||
type User struct {
|
type User struct {
|
||||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||||
@@ -104,12 +117,25 @@ type Setting struct {
|
|||||||
Value string `json:"value" form:"value"`
|
Value string `json:"value" form:"value"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CustomGeoResource struct {
|
||||||
|
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||||
|
Type string `json:"type" gorm:"not null;uniqueIndex:idx_custom_geo_type_alias;column:geo_type"`
|
||||||
|
Alias string `json:"alias" gorm:"not null;uniqueIndex:idx_custom_geo_type_alias"`
|
||||||
|
Url string `json:"url" gorm:"not null"`
|
||||||
|
LocalPath string `json:"localPath" gorm:"column:local_path"`
|
||||||
|
LastUpdatedAt int64 `json:"lastUpdatedAt" gorm:"default:0;column:last_updated_at"`
|
||||||
|
LastModified string `json:"lastModified" gorm:"column:last_modified"`
|
||||||
|
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime;column:created_at"`
|
||||||
|
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime;column:updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
// Client represents a client configuration for Xray inbounds with traffic limits and settings.
|
// Client represents a client configuration for Xray inbounds with traffic limits and settings.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
ID string `json:"id"` // Unique client identifier
|
ID string `json:"id,omitempty"` // Unique client identifier
|
||||||
Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm")
|
Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm")
|
||||||
Password string `json:"password"` // Client password
|
Password string `json:"password,omitempty"` // Client password
|
||||||
Flow string `json:"flow"` // Flow control (XTLS)
|
Flow string `json:"flow,omitempty"` // Flow control (XTLS)
|
||||||
|
Auth string `json:"auth,omitempty"` // Auth password (Hysteria)
|
||||||
Email string `json:"email"` // Client email identifier
|
Email string `json:"email"` // Client email identifier
|
||||||
LimitIP int `json:"limitIp"` // IP limit for this client
|
LimitIP int `json:"limitIp"` // IP limit for this client
|
||||||
TotalGB int64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB
|
TotalGB int64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestIsHysteria(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in Protocol
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{Hysteria, true},
|
||||||
|
{Hysteria2, true},
|
||||||
|
{VLESS, false},
|
||||||
|
{Shadowsocks, false},
|
||||||
|
{Protocol(""), false},
|
||||||
|
{Protocol("hysteria3"), false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := IsHysteria(c.in); got != c.want {
|
||||||
|
t.Errorf("IsHysteria(%q) = %v, want %v", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,53 +1,53 @@
|
|||||||
module github.com/mhsanaei/3x-ui/v2
|
module github.com/mhsanaei/3x-ui/v2
|
||||||
|
|
||||||
go 1.25.6
|
go 1.26.2
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gin-contrib/gzip v1.2.5
|
github.com/gin-contrib/gzip v1.2.6
|
||||||
github.com/gin-contrib/sessions v1.0.4
|
github.com/gin-contrib/sessions v1.1.0
|
||||||
github.com/gin-gonic/gin v1.11.0
|
github.com/gin-gonic/gin v1.12.0
|
||||||
github.com/go-ldap/ldap/v3 v3.4.12
|
github.com/go-ldap/ldap/v3 v3.4.13
|
||||||
github.com/goccy/go-json v0.10.5
|
github.com/goccy/go-json v0.10.6
|
||||||
|
github.com/goccy/go-yaml v1.19.2
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/mymmrac/telego v1.5.0
|
github.com/mymmrac/telego v1.8.0
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.6.1
|
github.com/nicksnyder/go-i18n/v2 v2.6.1
|
||||||
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
|
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4
|
github.com/pelletier/go-toml/v2 v2.3.0
|
||||||
github.com/robfig/cron/v3 v3.0.1
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
github.com/shirou/gopsutil/v4 v4.26.1
|
github.com/shirou/gopsutil/v4 v4.26.3
|
||||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||||
github.com/valyala/fasthttp v1.69.0
|
github.com/valyala/fasthttp v1.70.0
|
||||||
github.com/xlzd/gotp v0.1.0
|
github.com/xlzd/gotp v0.1.0
|
||||||
github.com/xtls/xray-core v1.260131.0
|
github.com/xtls/xray-core v1.260327.0
|
||||||
go.uber.org/atomic v1.11.0
|
go.uber.org/atomic v1.11.0
|
||||||
golang.org/x/crypto v0.47.0
|
golang.org/x/crypto v0.50.0
|
||||||
golang.org/x/sys v0.40.0
|
golang.org/x/sys v0.43.0
|
||||||
golang.org/x/text v0.33.0
|
golang.org/x/text v0.36.0
|
||||||
google.golang.org/grpc v1.78.0
|
google.golang.org/grpc v1.80.0
|
||||||
gorm.io/driver/sqlite v1.6.0
|
gorm.io/driver/sqlite v1.6.0
|
||||||
gorm.io/gorm v1.31.1
|
gorm.io/gorm v1.31.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Azure/go-ntlmssp v0.1.0 // indirect
|
github.com/Azure/go-ntlmssp v0.1.1 // indirect
|
||||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||||
github.com/apernet/quic-go v0.57.2-0.20260111184307-eec823306178 // indirect
|
github.com/apernet/quic-go v0.59.1-0.20260217092621-db4786c77a22 // indirect
|
||||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
github.com/bytedance/gopkg v0.1.4 // indirect
|
||||||
github.com/bytedance/sonic v1.15.0 // indirect
|
github.com/bytedance/sonic v1.15.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
github.com/bytedance/sonic/loader v0.5.1 // indirect
|
||||||
github.com/cloudflare/circl v1.6.3 // indirect
|
github.com/cloudflare/circl v1.6.3 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
github.com/ebitengine/purego v0.9.1 // indirect
|
github.com/ebitengine/purego v0.10.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
github.com/gin-contrib/sse v1.1.1 // indirect
|
||||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
||||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
github.com/go-playground/validator/v10 v10.30.2 // indirect
|
||||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
|
||||||
github.com/google/btree v1.1.3 // indirect
|
github.com/google/btree v1.1.3 // indirect
|
||||||
github.com/gorilla/context v1.1.2 // indirect
|
github.com/gorilla/context v1.1.2 // indirect
|
||||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||||
@@ -57,44 +57,45 @@ require (
|
|||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/juju/ratelimit v1.0.2 // indirect
|
github.com/juju/ratelimit v1.0.2 // indirect
|
||||||
github.com/klauspost/compress v1.18.3 // indirect
|
github.com/klauspost/compress v1.18.5 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
|
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.21 // indirect
|
||||||
github.com/mattn/go-sqlite3 v1.14.33 // indirect
|
github.com/mattn/go-sqlite3 v1.14.42 // indirect
|
||||||
github.com/miekg/dns v1.1.72 // indirect
|
github.com/miekg/dns v1.1.72 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pires/go-proxyproto v0.9.2 // indirect
|
github.com/pires/go-proxyproto v0.12.0 // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||||
github.com/quic-go/qpack v0.6.0 // indirect
|
github.com/quic-go/qpack v0.6.0 // indirect
|
||||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||||
github.com/refraction-networking/utls v1.8.2 // indirect
|
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af // indirect
|
||||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
github.com/sagernet/sing v0.7.18 // indirect
|
github.com/sagernet/sing v0.8.9 // indirect
|
||||||
github.com/sagernet/sing-shadowsocks v0.2.9 // indirect
|
github.com/sagernet/sing-shadowsocks v0.2.9 // indirect
|
||||||
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
||||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fastjson v1.6.7 // indirect
|
github.com/valyala/fastjson v1.6.10 // indirect
|
||||||
github.com/vishvananda/netlink v1.3.1 // indirect
|
github.com/vishvananda/netlink v1.3.1 // indirect
|
||||||
github.com/vishvananda/netns v0.0.5 // indirect
|
github.com/vishvananda/netns v0.0.5 // indirect
|
||||||
github.com/xtls/reality v0.0.0-20251116175510-cd53f7d50237 // indirect
|
github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.1 // indirect
|
||||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
||||||
golang.org/x/arch v0.23.0 // indirect
|
golang.org/x/arch v0.26.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
|
||||||
golang.org/x/mod v0.32.0 // indirect
|
golang.org/x/mod v0.35.0 // indirect
|
||||||
golang.org/x/net v0.49.0 // indirect
|
golang.org/x/net v0.53.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/time v0.14.0 // indirect
|
golang.org/x/time v0.15.0 // indirect
|
||||||
golang.org/x/tools v0.41.0 // indirect
|
golang.org/x/tools v0.44.0 // indirect
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
|
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
|
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
|
||||||
lukechampine.com/blake3 v1.4.1 // indirect
|
lukechampine.com/blake3 v1.4.1 // indirect
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
|
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
|
||||||
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
|
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
|
||||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
github.com/apernet/quic-go v0.57.2-0.20260111184307-eec823306178 h1:bSq8n+gX4oO/qnM3MKf4kroW75n+phO9Qp6nigJKZ1E=
|
github.com/apernet/quic-go v0.59.1-0.20260217092621-db4786c77a22 h1:00ziBGnLWQEcR9LThDwvxOznJJquJ9bYUdmBFnawLMU=
|
||||||
github.com/apernet/quic-go v0.57.2-0.20260111184307-eec823306178/go.mod h1:N1WIjPphkqs4efXWuyDNQ6OjjIK04vM3h+bEgwV+eVU=
|
github.com/apernet/quic-go v0.59.1-0.20260217092621-db4786c77a22/go.mod h1:Npbg8qBtAZlsAB3FWmqwlVh5jtVG6a4DlYsOylUpvzA=
|
||||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
|
||||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
|
||||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
|
||||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
@@ -21,24 +23,24 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE
|
|||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
|
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
|
||||||
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344 h1:Arcl6UOIS/kgO2nW3A65HN+7CMjSDP/gofXL4CZt1V4=
|
github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344 h1:Arcl6UOIS/kgO2nW3A65HN+7CMjSDP/gofXL4CZt1V4=
|
||||||
github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344/go.mod h1:GIjDIg/heH5DOkXY3YJ/wNhfHsQHoXGjl8G8amsYQ1I=
|
github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344/go.mod h1:GIjDIg/heH5DOkXY3YJ/wNhfHsQHoXGjl8G8amsYQ1I=
|
||||||
github.com/gin-contrib/gzip v1.2.5 h1:fIZs0S+l17pIu1P5XRJOo/YNqfIuPCrZZ3TWB7pjckI=
|
github.com/gin-contrib/gzip v1.2.6 h1:OtN8DplD5DNZCSLAnQ5HxRkD2qZ5VU+JhOrcfJrcRvg=
|
||||||
github.com/gin-contrib/gzip v1.2.5/go.mod h1:aomRgR7ftdZV3uWY0gW/m8rChfxau0n8YVvwlOHONzw=
|
github.com/gin-contrib/gzip v1.2.6/go.mod h1:BQy8/+JApnRjAVUplSGZiVtD2k8GmIE2e9rYu/hLzzU=
|
||||||
github.com/gin-contrib/sessions v1.0.4 h1:ha6CNdpYiTOK/hTp05miJLbpTSNfOnFg5Jm2kbcqy8U=
|
github.com/gin-contrib/sessions v1.1.0 h1:00mhHfNEGF5sP2fwxa98aRqj1FOJdL6IkR86n2hOiBo=
|
||||||
github.com/gin-contrib/sessions v1.0.4/go.mod h1:ccmkrb2z6iU2osiAHZG3x3J4suJK+OU27oqzlWOqQgs=
|
github.com/gin-contrib/sessions v1.1.0/go.mod h1:TyYZDIs6qCQg2SOoYPgMT9pAkmZceVNEJMcv5qbIy60=
|
||||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
|
||||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
|
||||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
|
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
|
||||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||||
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
|
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
|
||||||
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
|
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
@@ -52,10 +54,10 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
|
|||||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
|
||||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
|
||||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
github.com/golang/mock v1.7.0-rc.1 h1:YojYx61/OLFsiv6Rw1Z96LpldJIy31o+UHmwAUMJ6/U=
|
github.com/golang/mock v1.7.0-rc.1 h1:YojYx61/OLFsiv6Rw1Z96LpldJIy31o+UHmwAUMJ6/U=
|
||||||
@@ -105,8 +107,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
|
|||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/juju/ratelimit v1.0.2 h1:sRxmtRiajbvrcLQT7S+JbqU0ntsb9W2yhSdNN8tWfaI=
|
github.com/juju/ratelimit v1.0.2 h1:sRxmtRiajbvrcLQT7S+JbqU0ntsb9W2yhSdNN8tWfaI=
|
||||||
github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk=
|
github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk=
|
||||||
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
|
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||||
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
@@ -115,12 +117,12 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
|||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k=
|
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
|
||||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||||
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
|
github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo=
|
||||||
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
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 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
@@ -128,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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
github.com/mymmrac/telego v1.5.0 h1:VjBDZcSpEQim1Y3JX2WCsF/PJqOA2DKfZknXUvtKCnw=
|
github.com/mymmrac/telego v1.8.0 h1:EvIprWo9Cn0MHgumvvqNXPAXO1yJj3pu2cdCCeDxbow=
|
||||||
github.com/mymmrac/telego v1.5.0/go.mod h1:MDYHIeT68tURdcwH4SNCQQ+0xBC3u6wOcH2hBpa4Ip0=
|
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 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
|
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
|
||||||
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88=
|
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88=
|
||||||
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
|
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
|
||||||
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
|
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
|
||||||
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
github.com/pires/go-proxyproto v0.9.2 h1:H1UdHn695zUVVmB0lQ354lOWHOy6TZSpzBl3tgN0s1U=
|
github.com/pires/go-proxyproto v0.12.0 h1:TTCxD66dU898tahivkqc3hoceZp7P44FnorWyo9d5vM=
|
||||||
github.com/pires/go-proxyproto v0.9.2/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||||
@@ -148,18 +150,18 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
|||||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||||
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
|
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af h1:er2acxbi3N1nvEq6HXHUAR1nTWEJmQfqiGR8EVT9rfs=
|
||||||
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
github.com/sagernet/sing v0.7.18 h1:iZHkaru1/MoHugx3G+9S3WG4owMewKO/KvieE2Pzk4E=
|
github.com/sagernet/sing v0.8.9 h1:iX8FyMrWNl/divVgTe7cLT9n36v6bfzfnCYlcM1cLaU=
|
||||||
github.com/sagernet/sing v0.7.18/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak=
|
github.com/sagernet/sing v0.8.9/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak=
|
||||||
github.com/sagernet/sing-shadowsocks v0.2.9 h1:Paep5zCszRKsEn8587O0MnhFWKJwDW1Y4zOYYlIxMkM=
|
github.com/sagernet/sing-shadowsocks v0.2.9 h1:Paep5zCszRKsEn8587O0MnhFWKJwDW1Y4zOYYlIxMkM=
|
||||||
github.com/sagernet/sing-shadowsocks v0.2.9/go.mod h1:TE/Z6401Pi8tgr0nBZcM/xawAI6u3F6TTbz4nH/qw+8=
|
github.com/sagernet/sing-shadowsocks v0.2.9/go.mod h1:TE/Z6401Pi8tgr0nBZcM/xawAI6u3F6TTbz4nH/qw+8=
|
||||||
github.com/shirou/gopsutil/v4 v4.26.1 h1:TOkEyriIXk2HX9d4isZJtbjXbEjf5qyKPAzbzY0JWSo=
|
github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
|
||||||
github.com/shirou/gopsutil/v4 v4.26.1/go.mod h1:medLI9/UNAb0dOI9Q3/7yWSqKkj00u+1tgY8nvv41pc=
|
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
@@ -183,36 +185,38 @@ 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/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 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
|
github.com/valyala/fasthttp v1.70.0 h1:LAhMGcWk13QZWm85+eg8ZBNbrq5mnkWFGbHMUJHIdXA=
|
||||||
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
|
github.com/valyala/fasthttp v1.70.0/go.mod h1:oDZEHHkJ/Buyklg6uURmYs19442zFSnCIfX3j1FY3pE=
|
||||||
github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM=
|
github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
|
||||||
github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
|
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
|
||||||
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
|
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
|
||||||
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
|
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
|
||||||
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
|
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
|
||||||
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
||||||
github.com/xlzd/gotp v0.1.0 h1:37blvlKCh38s+fkem+fFh7sMnceltoIEBYTVXyoa5Po=
|
github.com/xlzd/gotp v0.1.0 h1:37blvlKCh38s+fkem+fFh7sMnceltoIEBYTVXyoa5Po=
|
||||||
github.com/xlzd/gotp v0.1.0/go.mod h1:ndLJ3JKzi3xLmUProq4LLxCuECL93dG9WASNLpHz8qg=
|
github.com/xlzd/gotp v0.1.0/go.mod h1:ndLJ3JKzi3xLmUProq4LLxCuECL93dG9WASNLpHz8qg=
|
||||||
github.com/xtls/reality v0.0.0-20251116175510-cd53f7d50237 h1:UXjrmniKlY+ZbIqpN91lejB3pszQQQRVu1vqH/p/aGM=
|
github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f h1:iy2JRioxmUpoJ3SzbFPyTxHZMbR/rSHP7dOOgYaq1O8=
|
||||||
github.com/xtls/reality v0.0.0-20251116175510-cd53f7d50237/go.mod h1:vbHCV/3VWUvy1oKvTxxWJRPEWSeR1sYgQHIh6u/JiZQ=
|
github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f/go.mod h1:DsJblcWDGt76+FVqBVwbwRhxyyNJsGV48gJLch0OOWI=
|
||||||
github.com/xtls/xray-core v1.260131.0 h1:gPBykLhUvRZ8sfubNerkwWqV3c15UtmSYQG2cgKqrV4=
|
github.com/xtls/xray-core v1.260327.0 h1:g4TzxMwyPrxslZh6uD+FiG3lXKTrnNO+b4ky2OhogHE=
|
||||||
github.com/xtls/xray-core v1.260131.0/go.mod h1:cxzYFZrxu1B1NtPjHsqv4UzgDvRA71mV4rXYH4KtO7Q=
|
github.com/xtls/xray-core v1.260327.0/go.mod h1:OXMlhBloFry8mw0KwWLWLd3RQyXJzEYsCGlgsX36h60=
|
||||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.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 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||||
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||||
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||||
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||||
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
|
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||||
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
|
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
|
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
|
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||||
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||||
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
@@ -221,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=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
|
||||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
||||||
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
|
golang.org/x/arch v0.26.0 h1:jZ6dpec5haP/fUv1kLCbuJy6dnRrfX6iVK08lZBFpk4=
|
||||||
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
golang.org/x/arch v0.26.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
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 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
||||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
|
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
|
||||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 h1:XF8+t6QQiS0o9ArVan/HW8Q7cycNPGsJf6GA2nXxYAg=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||||
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
+54
-29
@@ -76,37 +76,38 @@ is_port_in_use() {
|
|||||||
install_base() {
|
install_base() {
|
||||||
case "${release}" in
|
case "${release}" in
|
||||||
ubuntu | debian | armbian)
|
ubuntu | debian | armbian)
|
||||||
apt-get update && apt-get install -y -q curl tar tzdata socat ca-certificates
|
apt-get update && apt-get install -y -q cron curl tar tzdata socat ca-certificates openssl
|
||||||
;;
|
;;
|
||||||
fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol)
|
fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol)
|
||||||
dnf -y update && dnf install -y -q curl tar tzdata socat ca-certificates
|
dnf -y update && dnf install -y -q cronie curl tar tzdata socat ca-certificates openssl
|
||||||
;;
|
;;
|
||||||
centos)
|
centos)
|
||||||
if [[ "${VERSION_ID}" =~ ^7 ]]; then
|
if [[ "${VERSION_ID}" =~ ^7 ]]; then
|
||||||
yum -y update && yum install -y curl tar tzdata socat ca-certificates
|
yum -y update && yum install -y cronie curl tar tzdata socat ca-certificates openssl
|
||||||
else
|
else
|
||||||
dnf -y update && dnf install -y -q curl tar tzdata socat ca-certificates
|
dnf -y update && dnf install -y -q cronie curl tar tzdata socat ca-certificates openssl
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
arch | manjaro | parch)
|
arch | manjaro | parch)
|
||||||
pacman -Syu && pacman -Syu --noconfirm curl tar tzdata socat ca-certificates
|
pacman -Syu && pacman -Syu --noconfirm cronie curl tar tzdata socat ca-certificates openssl
|
||||||
;;
|
;;
|
||||||
opensuse-tumbleweed | opensuse-leap)
|
opensuse-tumbleweed | opensuse-leap)
|
||||||
zypper refresh && zypper -q install -y curl tar timezone socat ca-certificates
|
zypper refresh && zypper -q install -y cron curl tar timezone socat ca-certificates openssl
|
||||||
;;
|
;;
|
||||||
alpine)
|
alpine)
|
||||||
apk update && apk add curl tar tzdata socat ca-certificates
|
apk update && apk add dcron curl tar tzdata socat ca-certificates openssl
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
apt-get update && apt-get install -y -q curl tar tzdata socat ca-certificates
|
apt-get update && apt-get install -y -q cron curl tar tzdata socat ca-certificates openssl
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
gen_random_string() {
|
gen_random_string() {
|
||||||
local length="$1"
|
local length="$1"
|
||||||
local random_string=$(LC_ALL=C tr -dc 'a-zA-Z0-9' </dev/urandom | fold -w "$length" | head -n 1)
|
openssl rand -base64 $(( length * 2 )) \
|
||||||
echo "$random_string"
|
| tr -dc 'a-zA-Z0-9' \
|
||||||
|
| head -c "$length"
|
||||||
}
|
}
|
||||||
|
|
||||||
install_acme() {
|
install_acme() {
|
||||||
@@ -378,15 +379,15 @@ ssl_cert_issue() {
|
|||||||
break
|
break
|
||||||
done
|
done
|
||||||
echo -e "${green}Your domain is: ${domain}, checking it...${plain}"
|
echo -e "${green}Your domain is: ${domain}, checking it...${plain}"
|
||||||
|
SSL_ISSUED_DOMAIN="${domain}"
|
||||||
|
|
||||||
# check if there already exists a certificate
|
# detect existing certificate and reuse it if present
|
||||||
local currentCert=$(~/.acme.sh/acme.sh --list | tail -1 | awk '{print $1}')
|
local cert_exists=0
|
||||||
if [ "${currentCert}" == "${domain}" ]; then
|
if ~/.acme.sh/acme.sh --list 2>/dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
|
||||||
local certInfo=$(~/.acme.sh/acme.sh --list)
|
cert_exists=1
|
||||||
echo -e "${red}System already has certificates for this domain. Cannot issue again.${plain}"
|
local certInfo=$(~/.acme.sh/acme.sh --list 2>/dev/null | grep -F "${domain}")
|
||||||
echo -e "${yellow}Current certificate details:${plain}"
|
echo -e "${yellow}Existing certificate found for ${domain}, will reuse it.${plain}"
|
||||||
echo "$certInfo"
|
[[ -n "${certInfo}" ]] && echo "$certInfo"
|
||||||
return 1
|
|
||||||
else
|
else
|
||||||
echo -e "${green}Your domain is ready for issuing certificates now...${plain}"
|
echo -e "${green}Your domain is ready for issuing certificates now...${plain}"
|
||||||
fi
|
fi
|
||||||
@@ -413,6 +414,7 @@ ssl_cert_issue() {
|
|||||||
echo -e "${yellow}Stopping panel temporarily...${plain}"
|
echo -e "${yellow}Stopping panel temporarily...${plain}"
|
||||||
systemctl stop x-ui 2>/dev/null || rc-service x-ui stop 2>/dev/null
|
systemctl stop x-ui 2>/dev/null || rc-service x-ui stop 2>/dev/null
|
||||||
|
|
||||||
|
if [[ ${cert_exists} -eq 0 ]]; then
|
||||||
# issue the certificate
|
# issue the certificate
|
||||||
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force
|
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force
|
||||||
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
|
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
|
||||||
@@ -424,6 +426,9 @@ ssl_cert_issue() {
|
|||||||
else
|
else
|
||||||
echo -e "${green}Issuing certificate succeeded, installing certificates...${plain}"
|
echo -e "${green}Issuing certificate succeeded, installing certificates...${plain}"
|
||||||
fi
|
fi
|
||||||
|
else
|
||||||
|
echo -e "${green}Using existing certificate, installing certificates...${plain}"
|
||||||
|
fi
|
||||||
|
|
||||||
# Setup reload command
|
# Setup reload command
|
||||||
reloadCmd="systemctl restart x-ui || rc-service x-ui restart"
|
reloadCmd="systemctl restart x-ui || rc-service x-ui restart"
|
||||||
@@ -452,17 +457,27 @@ ssl_cert_issue() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# install the certificate
|
# install the certificate
|
||||||
~/.acme.sh/acme.sh --installcert -d ${domain} \
|
local installOutput=""
|
||||||
|
installOutput=$(~/.acme.sh/acme.sh --installcert -d ${domain} \
|
||||||
--key-file /root/cert/${domain}/privkey.pem \
|
--key-file /root/cert/${domain}/privkey.pem \
|
||||||
--fullchain-file /root/cert/${domain}/fullchain.pem --reloadcmd "${reloadCmd}"
|
--fullchain-file /root/cert/${domain}/fullchain.pem --reloadcmd "${reloadCmd}" 2>&1)
|
||||||
|
local installRc=$?
|
||||||
|
echo "${installOutput}"
|
||||||
|
|
||||||
if [ $? -ne 0 ]; then
|
local installWroteFiles=0
|
||||||
|
if echo "${installOutput}" | grep -q "Installing key to:" && echo "${installOutput}" | grep -q "Installing full chain to:"; then
|
||||||
|
installWroteFiles=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -f "/root/cert/${domain}/privkey.pem" && -f "/root/cert/${domain}/fullchain.pem" && ( ${installRc} -eq 0 || ${installWroteFiles} -eq 1 ) ]]; then
|
||||||
|
echo -e "${green}Installing certificate succeeded, enabling auto renew...${plain}"
|
||||||
|
else
|
||||||
echo -e "${red}Installing certificate failed, exiting.${plain}"
|
echo -e "${red}Installing certificate failed, exiting.${plain}"
|
||||||
|
if [[ ${cert_exists} -eq 0 ]]; then
|
||||||
rm -rf ~/.acme.sh/${domain}
|
rm -rf ~/.acme.sh/${domain}
|
||||||
|
fi
|
||||||
systemctl start x-ui 2>/dev/null || rc-service x-ui start 2>/dev/null
|
systemctl start x-ui 2>/dev/null || rc-service x-ui start 2>/dev/null
|
||||||
return 1
|
return 1
|
||||||
else
|
|
||||||
echo -e "${green}Installing certificate succeeded, enabling auto renew...${plain}"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# enable auto-renew
|
# enable auto-renew
|
||||||
@@ -535,9 +550,12 @@ prompt_and_setup_ssl() {
|
|||||||
1)
|
1)
|
||||||
# User chose Let's Encrypt domain option
|
# User chose Let's Encrypt domain option
|
||||||
echo -e "${green}Using Let's Encrypt for domain certificate...${plain}"
|
echo -e "${green}Using Let's Encrypt for domain certificate...${plain}"
|
||||||
ssl_cert_issue
|
if ssl_cert_issue; then
|
||||||
# Extract the domain that was used from the certificate
|
local cert_domain="${SSL_ISSUED_DOMAIN}"
|
||||||
local cert_domain=$(~/.acme.sh/acme.sh --list 2>/dev/null | tail -1 | awk '{print $1}')
|
if [[ -z "${cert_domain}" ]]; then
|
||||||
|
cert_domain=$(~/.acme.sh/acme.sh --list 2>/dev/null | tail -1 | awk '{print $1}')
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ -n "${cert_domain}" ]]; then
|
if [[ -n "${cert_domain}" ]]; then
|
||||||
SSL_HOST="${cert_domain}"
|
SSL_HOST="${cert_domain}"
|
||||||
echo -e "${green}✓ SSL certificate configured successfully with domain: ${cert_domain}${plain}"
|
echo -e "${green}✓ SSL certificate configured successfully with domain: ${cert_domain}${plain}"
|
||||||
@@ -545,6 +563,10 @@ prompt_and_setup_ssl() {
|
|||||||
echo -e "${yellow}SSL setup may have completed, but domain extraction failed${plain}"
|
echo -e "${yellow}SSL setup may have completed, but domain extraction failed${plain}"
|
||||||
SSL_HOST="${server_ip}"
|
SSL_HOST="${server_ip}"
|
||||||
fi
|
fi
|
||||||
|
else
|
||||||
|
echo -e "${red}SSL certificate setup failed for domain mode.${plain}"
|
||||||
|
SSL_HOST="${server_ip}"
|
||||||
|
fi
|
||||||
;;
|
;;
|
||||||
2)
|
2)
|
||||||
# User chose Let's Encrypt IP certificate option
|
# User chose Let's Encrypt IP certificate option
|
||||||
@@ -580,7 +602,7 @@ prompt_and_setup_ssl() {
|
|||||||
|
|
||||||
# 3.1 Request Domain to compose Panel URL later
|
# 3.1 Request Domain to compose Panel URL later
|
||||||
read -rp "Please enter domain name certificate issued for: " custom_domain
|
read -rp "Please enter domain name certificate issued for: " custom_domain
|
||||||
custom_domain="${custom_domain// /}" # Убираем пробелы
|
custom_domain="${custom_domain// /}" # Remove spaces
|
||||||
|
|
||||||
# 3.2 Loop for Certificate Path
|
# 3.2 Loop for Certificate Path
|
||||||
while true; do
|
while true; do
|
||||||
@@ -654,8 +676,11 @@ config_after_install() {
|
|||||||
)
|
)
|
||||||
local server_ip=""
|
local server_ip=""
|
||||||
for ip_address in "${URL_lists[@]}"; do
|
for ip_address in "${URL_lists[@]}"; do
|
||||||
server_ip=$(curl -s --max-time 3 "${ip_address}" 2>/dev/null | tr -d '[:space:]')
|
local response=$(curl -s -w "\n%{http_code}" --max-time 3 "${ip_address}" 2>/dev/null)
|
||||||
if [[ -n "${server_ip}" ]]; then
|
local http_code=$(echo "$response" | tail -n1)
|
||||||
|
local ip_result=$(echo "$response" | head -n-1 | tr -d '[:space:]')
|
||||||
|
if [[ "${http_code}" == "200" && -n "${ip_result}" ]]; then
|
||||||
|
server_ip="${ip_result}"
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"github.com/mhsanaei/3x-ui/v2/logger"
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||||
"github.com/mhsanaei/3x-ui/v2/sub"
|
"github.com/mhsanaei/3x-ui/v2/sub"
|
||||||
"github.com/mhsanaei/3x-ui/v2/util/crypto"
|
"github.com/mhsanaei/3x-ui/v2/util/crypto"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/util/sys"
|
||||||
"github.com/mhsanaei/3x-ui/v2/web"
|
"github.com/mhsanaei/3x-ui/v2/web"
|
||||||
"github.com/mhsanaei/3x-ui/v2/web/global"
|
"github.com/mhsanaei/3x-ui/v2/web/global"
|
||||||
"github.com/mhsanaei/3x-ui/v2/web/service"
|
"github.com/mhsanaei/3x-ui/v2/web/service"
|
||||||
@@ -70,7 +71,7 @@ func runWebServer() {
|
|||||||
|
|
||||||
sigCh := make(chan os.Signal, 1)
|
sigCh := make(chan os.Signal, 1)
|
||||||
// Trap shutdown signals
|
// Trap shutdown signals
|
||||||
signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGTERM)
|
signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGTERM, sys.SIGUSR1)
|
||||||
for {
|
for {
|
||||||
sig := <-sigCh
|
sig := <-sigCh
|
||||||
|
|
||||||
@@ -108,6 +109,12 @@ func runWebServer() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Println("Sub server restarted successfully.")
|
log.Println("Sub server restarted successfully.")
|
||||||
|
case sys.SIGUSR1:
|
||||||
|
logger.Info("Received USR1 signal, restarting xray-core...")
|
||||||
|
err := server.RestartXray()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Failed to restart xray-core:", err)
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// --- FIX FOR TELEGRAM BOT CONFLICT (409) on full shutdown ---
|
// --- FIX FOR TELEGRAM BOT CONFLICT (409) on full shutdown ---
|
||||||
@@ -123,20 +130,22 @@ func runWebServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// resetSetting resets all panel settings to their default values.
|
// resetSetting resets all panel settings to their default values.
|
||||||
func resetSetting() {
|
func resetSetting() error {
|
||||||
err := database.InitDB(config.GetDBPath())
|
err := database.InitDB(config.GetDBPath())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("Failed to initialize database:", err)
|
fmt.Println("Failed to initialize database:", err)
|
||||||
return
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
settingService := service.SettingService{}
|
settingService := service.SettingService{}
|
||||||
err = settingService.ResetSettings()
|
err = settingService.ResetSettings()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("Failed to reset settings:", err)
|
fmt.Println("Failed to reset settings:", err)
|
||||||
|
return err
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("Settings successfully reset.")
|
fmt.Println("Settings successfully reset.")
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// showSetting displays the current panel settings if show is true.
|
// showSetting displays the current panel settings if show is true.
|
||||||
@@ -248,11 +257,11 @@ func updateTgbotSetting(tgBotToken string, tgBotChatid string, tgBotRuntime stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
// updateSetting updates various panel settings including port, credentials, base path, listen IP, and two-factor authentication.
|
// updateSetting updates various panel settings including port, credentials, base path, listen IP, and two-factor authentication.
|
||||||
func updateSetting(port int, username string, password string, webBasePath string, listenIP string, resetTwoFactor bool) {
|
func updateSetting(port int, username string, password string, webBasePath string, listenIP string, resetTwoFactor bool) error {
|
||||||
err := database.InitDB(config.GetDBPath())
|
err := database.InitDB(config.GetDBPath())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("Database initialization failed:", err)
|
fmt.Println("Database initialization failed:", err)
|
||||||
return
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
settingService := service.SettingService{}
|
settingService := service.SettingService{}
|
||||||
@@ -304,6 +313,8 @@ func updateSetting(port int, username string, password string, webBasePath strin
|
|||||||
fmt.Printf("listen %v set successfully", listenIP)
|
fmt.Printf("listen %v set successfully", listenIP)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateCert updates the SSL certificate files for the panel.
|
// updateCert updates the SSL certificate files for the panel.
|
||||||
@@ -474,9 +485,13 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if reset {
|
if reset {
|
||||||
resetSetting()
|
if err = resetSetting(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
updateSetting(port, username, password, webBasePath, listenIP, resetTwoFactor)
|
if err = updateSetting(port, username, password, webBasePath, listenIP, resetTwoFactor); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if show {
|
if show {
|
||||||
showSetting(show)
|
showSetting(show)
|
||||||
|
|||||||
+11
-2
@@ -91,12 +91,21 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine if JSON subscription endpoint is enabled
|
ClashPath, err := s.settingService.GetSubClashPath()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
subJsonEnable, err := s.settingService.GetSubJsonEnable()
|
subJsonEnable, err := s.settingService.GetSubJsonEnable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
subClashEnable, err := s.settingService.GetSubClashEnable()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
// Set base_path based on LinksPath for template rendering
|
// Set base_path based on LinksPath for template rendering
|
||||||
// Ensure LinksPath ends with "/" for proper asset URL generation
|
// Ensure LinksPath ends with "/" for proper asset URL generation
|
||||||
basePath := LinksPath
|
basePath := LinksPath
|
||||||
@@ -255,7 +264,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
|||||||
g := engine.Group("/")
|
g := engine.Group("/")
|
||||||
|
|
||||||
s.sub = NewSUBController(
|
s.sub = NewSUBController(
|
||||||
g, LinksPath, JsonPath, subJsonEnable, Encrypt, ShowInfo, RemarkModel, SubUpdates,
|
g, LinksPath, JsonPath, ClashPath, subJsonEnable, subClashEnable, Encrypt, ShowInfo, RemarkModel, SubUpdates,
|
||||||
SubJsonFragment, SubJsonNoises, SubJsonMux, SubJsonRules, SubTitle, SubSupportUrl,
|
SubJsonFragment, SubJsonNoises, SubJsonMux, SubJsonRules, SubTitle, SubSupportUrl,
|
||||||
SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules)
|
SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,468 @@
|
|||||||
|
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 {
|
||||||
|
// Hysteria has its own transport + TLS model, applyTransport /
|
||||||
|
// applySecurity don't fit. IsHysteria also covers the literal
|
||||||
|
// "hysteria2" protocol string (#4081).
|
||||||
|
if model.IsHysteria(inbound.Protocol) {
|
||||||
|
return s.buildHysteriaProxy(inbound, client, extraRemark)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildHysteriaProxy produces a mihomo-compatible Clash entry for a
|
||||||
|
// Hysteria (v1) or Hysteria2 inbound. It reads `inbound.StreamSettings`
|
||||||
|
// directly instead of going through streamData/tlsData, because those
|
||||||
|
// helpers prune fields (like `allowInsecure` / the salamander obfs
|
||||||
|
// block) that the hysteria proxy wants preserved.
|
||||||
|
func (s *SubClashService) buildHysteriaProxy(inbound *model.Inbound, client model.Client, extraRemark string) map[string]any {
|
||||||
|
var inboundSettings map[string]any
|
||||||
|
_ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
|
||||||
|
|
||||||
|
proxyType := "hysteria2"
|
||||||
|
authKey := "password"
|
||||||
|
if v, ok := inboundSettings["version"].(float64); ok && int(v) == 1 {
|
||||||
|
proxyType = "hysteria"
|
||||||
|
authKey = "auth-str"
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy := map[string]any{
|
||||||
|
"name": s.SubService.genRemark(inbound, client.Email, extraRemark),
|
||||||
|
"type": proxyType,
|
||||||
|
"server": inbound.Listen,
|
||||||
|
"port": inbound.Port,
|
||||||
|
"udp": true,
|
||||||
|
authKey: client.Auth,
|
||||||
|
}
|
||||||
|
|
||||||
|
var rawStream map[string]any
|
||||||
|
_ = json.Unmarshal([]byte(inbound.StreamSettings), &rawStream)
|
||||||
|
|
||||||
|
// TLS details — hysteria always uses TLS.
|
||||||
|
if tlsSettings, ok := rawStream["tlsSettings"].(map[string]any); ok {
|
||||||
|
if serverName, ok := tlsSettings["serverName"].(string); ok && serverName != "" {
|
||||||
|
proxy["sni"] = serverName
|
||||||
|
}
|
||||||
|
if alpnList, ok := tlsSettings["alpn"].([]any); ok && len(alpnList) > 0 {
|
||||||
|
out := make([]string, 0, len(alpnList))
|
||||||
|
for _, a := range alpnList {
|
||||||
|
if s, ok := a.(string); ok && s != "" {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) > 0 {
|
||||||
|
proxy["alpn"] = out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if inner, ok := tlsSettings["settings"].(map[string]any); ok {
|
||||||
|
if insecure, ok := inner["allowInsecure"].(bool); ok && insecure {
|
||||||
|
proxy["skip-cert-verify"] = true
|
||||||
|
}
|
||||||
|
if fp, ok := inner["fingerprint"].(string); ok && fp != "" {
|
||||||
|
proxy["client-fingerprint"] = fp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Salamander obfs (Hysteria2). Read the same finalmask.udp[salamander]
|
||||||
|
// block the subscription link generator uses.
|
||||||
|
if finalmask, ok := rawStream["finalmask"].(map[string]any); ok {
|
||||||
|
if udpMasks, ok := finalmask["udp"].([]any); ok {
|
||||||
|
for _, m := range udpMasks {
|
||||||
|
mask, _ := m.(map[string]any)
|
||||||
|
if mask == nil || mask["type"] != "salamander" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
settings, _ := mask["settings"].(map[string]any)
|
||||||
|
if pw, ok := settings["password"].(string); ok && pw != "" {
|
||||||
|
proxy["obfs"] = "salamander"
|
||||||
|
proxy["obfs-password"] = pw
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
+60
-7
@@ -3,8 +3,8 @@ package sub
|
|||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v2/config"
|
"github.com/mhsanaei/3x-ui/v2/config"
|
||||||
|
|
||||||
@@ -21,12 +21,15 @@ type SUBController struct {
|
|||||||
subRoutingRules string
|
subRoutingRules string
|
||||||
subPath string
|
subPath string
|
||||||
subJsonPath string
|
subJsonPath string
|
||||||
|
subClashPath string
|
||||||
jsonEnabled bool
|
jsonEnabled bool
|
||||||
|
clashEnabled bool
|
||||||
subEncrypt bool
|
subEncrypt bool
|
||||||
updateInterval string
|
updateInterval string
|
||||||
|
|
||||||
subService *SubService
|
subService *SubService
|
||||||
subJsonService *SubJsonService
|
subJsonService *SubJsonService
|
||||||
|
subClashService *SubClashService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSUBController creates a new subscription controller with the given configuration.
|
// NewSUBController creates a new subscription controller with the given configuration.
|
||||||
@@ -34,7 +37,9 @@ func NewSUBController(
|
|||||||
g *gin.RouterGroup,
|
g *gin.RouterGroup,
|
||||||
subPath string,
|
subPath string,
|
||||||
jsonPath string,
|
jsonPath string,
|
||||||
|
clashPath string,
|
||||||
jsonEnabled bool,
|
jsonEnabled bool,
|
||||||
|
clashEnabled bool,
|
||||||
encrypt bool,
|
encrypt bool,
|
||||||
showInfo bool,
|
showInfo bool,
|
||||||
rModel string,
|
rModel string,
|
||||||
@@ -60,12 +65,15 @@ func NewSUBController(
|
|||||||
subRoutingRules: subRoutingRules,
|
subRoutingRules: subRoutingRules,
|
||||||
subPath: subPath,
|
subPath: subPath,
|
||||||
subJsonPath: jsonPath,
|
subJsonPath: jsonPath,
|
||||||
|
subClashPath: clashPath,
|
||||||
jsonEnabled: jsonEnabled,
|
jsonEnabled: jsonEnabled,
|
||||||
|
clashEnabled: clashEnabled,
|
||||||
subEncrypt: encrypt,
|
subEncrypt: encrypt,
|
||||||
updateInterval: update,
|
updateInterval: update,
|
||||||
|
|
||||||
subService: sub,
|
subService: sub,
|
||||||
subJsonService: NewSubJsonService(jsonFragment, jsonNoise, jsonMux, jsonRules, sub),
|
subJsonService: NewSubJsonService(jsonFragment, jsonNoise, jsonMux, jsonRules, sub),
|
||||||
|
subClashService: NewSubClashService(sub),
|
||||||
}
|
}
|
||||||
a.initRouter(g)
|
a.initRouter(g)
|
||||||
return a
|
return a
|
||||||
@@ -80,6 +88,10 @@ func (a *SUBController) initRouter(g *gin.RouterGroup) {
|
|||||||
gJson := g.Group(a.subJsonPath)
|
gJson := g.Group(a.subJsonPath)
|
||||||
gJson.GET(":subid", a.subJsons)
|
gJson.GET(":subid", a.subJsons)
|
||||||
}
|
}
|
||||||
|
if a.clashEnabled {
|
||||||
|
gClash := g.Group(a.subClashPath)
|
||||||
|
gClash.GET(":subid", a.subClashs)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// subs handles HTTP requests for subscription links, returning either HTML page or base64-encoded subscription data.
|
// subs handles HTTP requests for subscription links, returning either HTML page or base64-encoded subscription data.
|
||||||
@@ -99,10 +111,13 @@ func (a *SUBController) subs(c *gin.Context) {
|
|||||||
accept := c.GetHeader("Accept")
|
accept := c.GetHeader("Accept")
|
||||||
if strings.Contains(strings.ToLower(accept), "text/html") || c.Query("html") == "1" || strings.EqualFold(c.Query("view"), "html") {
|
if strings.Contains(strings.ToLower(accept), "text/html") || c.Query("html") == "1" || strings.EqualFold(c.Query("view"), "html") {
|
||||||
// Build page data in service
|
// Build page data in service
|
||||||
subURL, subJsonURL := a.subService.BuildURLs(scheme, hostWithPort, a.subPath, a.subJsonPath, subId)
|
subURL, subJsonURL, subClashURL := a.subService.BuildURLs(scheme, hostWithPort, a.subPath, a.subJsonPath, a.subClashPath, subId)
|
||||||
if !a.jsonEnabled {
|
if !a.jsonEnabled {
|
||||||
subJsonURL = ""
|
subJsonURL = ""
|
||||||
}
|
}
|
||||||
|
if !a.clashEnabled {
|
||||||
|
subClashURL = ""
|
||||||
|
}
|
||||||
// Get base_path from context (set by middleware)
|
// Get base_path from context (set by middleware)
|
||||||
basePath, exists := c.Get("base_path")
|
basePath, exists := c.Get("base_path")
|
||||||
if !exists {
|
if !exists {
|
||||||
@@ -116,7 +131,7 @@ func (a *SUBController) subs(c *gin.Context) {
|
|||||||
// Remove trailing slash if exists, add subId, then add trailing slash
|
// Remove trailing slash if exists, add subId, then add trailing slash
|
||||||
basePathStr = strings.TrimRight(basePathStr, "/") + "/" + subId + "/"
|
basePathStr = strings.TrimRight(basePathStr, "/") + "/" + subId + "/"
|
||||||
}
|
}
|
||||||
page := a.subService.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, subURL, subJsonURL, basePathStr)
|
page := a.subService.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, subURL, subJsonURL, subClashURL, basePathStr)
|
||||||
c.HTML(200, "subpage.html", gin.H{
|
c.HTML(200, "subpage.html", gin.H{
|
||||||
"title": "subscription.title",
|
"title": "subscription.title",
|
||||||
"cur_ver": config.GetVersion(),
|
"cur_ver": config.GetVersion(),
|
||||||
@@ -136,6 +151,7 @@ func (a *SUBController) subs(c *gin.Context) {
|
|||||||
"totalByte": page.TotalByte,
|
"totalByte": page.TotalByte,
|
||||||
"subUrl": page.SubUrl,
|
"subUrl": page.SubUrl,
|
||||||
"subJsonUrl": page.SubJsonUrl,
|
"subJsonUrl": page.SubJsonUrl,
|
||||||
|
"subClashUrl": page.SubClashUrl,
|
||||||
"result": page.Result,
|
"result": page.Result,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -143,7 +159,11 @@ func (a *SUBController) subs(c *gin.Context) {
|
|||||||
|
|
||||||
// Add headers
|
// Add headers
|
||||||
header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
|
header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
|
||||||
a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, a.subProfileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules)
|
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)
|
||||||
|
|
||||||
if a.subEncrypt {
|
if a.subEncrypt {
|
||||||
c.String(200, base64.StdEncoding.EncodeToString([]byte(result)))
|
c.String(200, base64.StdEncoding.EncodeToString([]byte(result)))
|
||||||
@@ -156,18 +176,37 @@ func (a *SUBController) subs(c *gin.Context) {
|
|||||||
// subJsons handles HTTP requests for JSON subscription configurations.
|
// subJsons handles HTTP requests for JSON subscription configurations.
|
||||||
func (a *SUBController) subJsons(c *gin.Context) {
|
func (a *SUBController) subJsons(c *gin.Context) {
|
||||||
subId := c.Param("subid")
|
subId := c.Param("subid")
|
||||||
_, host, _, _ := a.subService.ResolveRequest(c)
|
scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
|
||||||
jsonSub, header, err := a.subJsonService.GetJson(subId, host)
|
jsonSub, header, err := a.subJsonService.GetJson(subId, host)
|
||||||
if err != nil || len(jsonSub) == 0 {
|
if err != nil || len(jsonSub) == 0 {
|
||||||
c.String(400, "Error!")
|
c.String(400, "Error!")
|
||||||
} else {
|
} else {
|
||||||
// Add headers
|
profileUrl := a.subProfileUrl
|
||||||
a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, a.subProfileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules)
|
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.String(200, jsonSub)
|
c.String(200, jsonSub)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *SUBController) subClashs(c *gin.Context) {
|
||||||
|
subId := c.Param("subid")
|
||||||
|
scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
|
||||||
|
clashSub, header, err := a.subClashService.GetClash(subId, host)
|
||||||
|
if err != nil || len(clashSub) == 0 {
|
||||||
|
c.String(400, "Error!")
|
||||||
|
} else {
|
||||||
|
profileUrl := a.subProfileUrl
|
||||||
|
if profileUrl == "" {
|
||||||
|
profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
|
||||||
|
}
|
||||||
|
a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules)
|
||||||
|
c.Data(200, "application/yaml; charset=utf-8", []byte(clashSub))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ApplyCommonHeaders sets common HTTP headers for subscription responses including user info, update interval, and profile title.
|
// ApplyCommonHeaders sets common HTTP headers for subscription responses including user info, update interval, and profile title.
|
||||||
func (a *SUBController) ApplyCommonHeaders(
|
func (a *SUBController) ApplyCommonHeaders(
|
||||||
c *gin.Context,
|
c *gin.Context,
|
||||||
@@ -182,10 +221,24 @@ func (a *SUBController) ApplyCommonHeaders(
|
|||||||
) {
|
) {
|
||||||
c.Writer.Header().Set("Subscription-Userinfo", header)
|
c.Writer.Header().Set("Subscription-Userinfo", header)
|
||||||
c.Writer.Header().Set("Profile-Update-Interval", updateInterval)
|
c.Writer.Header().Set("Profile-Update-Interval", updateInterval)
|
||||||
|
|
||||||
|
//Basics
|
||||||
|
if profileTitle != "" {
|
||||||
c.Writer.Header().Set("Profile-Title", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileTitle)))
|
c.Writer.Header().Set("Profile-Title", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileTitle)))
|
||||||
|
}
|
||||||
|
if profileSupportUrl != "" {
|
||||||
c.Writer.Header().Set("Support-Url", profileSupportUrl)
|
c.Writer.Header().Set("Support-Url", profileSupportUrl)
|
||||||
|
}
|
||||||
|
if profileUrl != "" {
|
||||||
c.Writer.Header().Set("Profile-Web-Page-Url", profileUrl)
|
c.Writer.Header().Set("Profile-Web-Page-Url", profileUrl)
|
||||||
|
}
|
||||||
|
if profileAnnounce != "" {
|
||||||
c.Writer.Header().Set("Announce", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileAnnounce)))
|
c.Writer.Header().Set("Announce", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileAnnounce)))
|
||||||
|
}
|
||||||
|
|
||||||
|
//Advanced (Happ)
|
||||||
c.Writer.Header().Set("Routing-Enable", strconv.FormatBool(profileEnableRouting))
|
c.Writer.Header().Set("Routing-Enable", strconv.FormatBool(profileEnableRouting))
|
||||||
|
if profileRoutingRules != "" {
|
||||||
c.Writer.Header().Set("Routing", profileRoutingRules)
|
c.Writer.Header().Set("Routing", profileRoutingRules)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
+92
-27
@@ -22,8 +22,7 @@ var defaultJson string
|
|||||||
type SubJsonService struct {
|
type SubJsonService struct {
|
||||||
configJson map[string]any
|
configJson map[string]any
|
||||||
defaultOutbounds []json_util.RawMessage
|
defaultOutbounds []json_util.RawMessage
|
||||||
fragment string
|
fragmentOrNoises bool
|
||||||
noises string
|
|
||||||
mux string
|
mux string
|
||||||
|
|
||||||
inboundService service.InboundService
|
inboundService service.InboundService
|
||||||
@@ -42,6 +41,31 @@ func NewSubJsonService(fragment string, noises string, mux string, rules string,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fragmentOrNoises := false
|
||||||
|
if fragment != "" || noises != "" {
|
||||||
|
fragmentOrNoises = true
|
||||||
|
defaultOutboundsSettings := map[string]interface{}{
|
||||||
|
"domainStrategy": "UseIP",
|
||||||
|
"redirect": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
if fragment != "" {
|
||||||
|
defaultOutboundsSettings["fragment"] = json_util.RawMessage(fragment)
|
||||||
|
}
|
||||||
|
|
||||||
|
if noises != "" {
|
||||||
|
defaultOutboundsSettings["noises"] = json_util.RawMessage(noises)
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultDirectOutbound := map[string]interface{}{
|
||||||
|
"protocol": "freedom",
|
||||||
|
"settings": defaultOutboundsSettings,
|
||||||
|
"tag": "direct_out",
|
||||||
|
}
|
||||||
|
jsonBytes, _ := json.MarshalIndent(defaultDirectOutbound, "", " ")
|
||||||
|
defaultOutbounds = append(defaultOutbounds, jsonBytes)
|
||||||
|
}
|
||||||
|
|
||||||
if rules != "" {
|
if rules != "" {
|
||||||
var newRules []any
|
var newRules []any
|
||||||
routing, _ := configJson["routing"].(map[string]any)
|
routing, _ := configJson["routing"].(map[string]any)
|
||||||
@@ -52,19 +76,10 @@ func NewSubJsonService(fragment string, noises string, mux string, rules string,
|
|||||||
configJson["routing"] = routing
|
configJson["routing"] = routing
|
||||||
}
|
}
|
||||||
|
|
||||||
if fragment != "" {
|
|
||||||
defaultOutbounds = append(defaultOutbounds, json_util.RawMessage(fragment))
|
|
||||||
}
|
|
||||||
|
|
||||||
if noises != "" {
|
|
||||||
defaultOutbounds = append(defaultOutbounds, json_util.RawMessage(noises))
|
|
||||||
}
|
|
||||||
|
|
||||||
return &SubJsonService{
|
return &SubJsonService{
|
||||||
configJson: configJson,
|
configJson: configJson,
|
||||||
defaultOutbounds: defaultOutbounds,
|
defaultOutbounds: defaultOutbounds,
|
||||||
fragment: fragment,
|
fragmentOrNoises: fragmentOrNoises,
|
||||||
noises: noises,
|
|
||||||
mux: mux,
|
mux: mux,
|
||||||
SubService: subService,
|
SubService: subService,
|
||||||
}
|
}
|
||||||
@@ -194,6 +209,8 @@ func (s *SubJsonService) getConfig(inbound *model.Inbound, client model.Client,
|
|||||||
newOutbounds = append(newOutbounds, s.genVless(inbound, streamSettings, client))
|
newOutbounds = append(newOutbounds, s.genVless(inbound, streamSettings, client))
|
||||||
case "trojan", "shadowsocks":
|
case "trojan", "shadowsocks":
|
||||||
newOutbounds = append(newOutbounds, s.genServer(inbound, streamSettings, client))
|
newOutbounds = append(newOutbounds, s.genServer(inbound, streamSettings, client))
|
||||||
|
case "hysteria", "hysteria2":
|
||||||
|
newOutbounds = append(newOutbounds, s.genHy(inbound, newStream, client))
|
||||||
}
|
}
|
||||||
|
|
||||||
newOutbounds = append(newOutbounds, s.defaultOutbounds...)
|
newOutbounds = append(newOutbounds, s.defaultOutbounds...)
|
||||||
@@ -222,8 +239,8 @@ func (s *SubJsonService) streamData(stream string) map[string]any {
|
|||||||
}
|
}
|
||||||
delete(streamSettings, "sockopt")
|
delete(streamSettings, "sockopt")
|
||||||
|
|
||||||
if s.fragment != "" {
|
if s.fragmentOrNoises {
|
||||||
streamSettings["sockopt"] = json_util.RawMessage(`{"dialerProxy": "fragment", "tcpKeepAliveIdle": 100, "tcpMptcp": true, "penetrate": true}`)
|
streamSettings["sockopt"] = json_util.RawMessage(`{"dialerProxy": "direct_out", "tcpKeepAliveIdle": 100}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove proxy protocol
|
// remove proxy protocol
|
||||||
@@ -253,9 +270,6 @@ func (s *SubJsonService) tlsData(tData map[string]any) map[string]any {
|
|||||||
|
|
||||||
tlsData["serverName"] = tData["serverName"]
|
tlsData["serverName"] = tData["serverName"]
|
||||||
tlsData["alpn"] = tData["alpn"]
|
tlsData["alpn"] = tData["alpn"]
|
||||||
if allowInsecure, ok := tlsClientSettings["allowInsecure"].(bool); ok {
|
|
||||||
tlsData["allowInsecure"] = allowInsecure
|
|
||||||
}
|
|
||||||
if fingerprint, ok := tlsClientSettings["fingerprint"].(string); ok {
|
if fingerprint, ok := tlsClientSettings["fingerprint"].(string); ok {
|
||||||
tlsData["fingerprint"] = fingerprint
|
tlsData["fingerprint"] = fingerprint
|
||||||
}
|
}
|
||||||
@@ -325,22 +339,30 @@ func (s *SubJsonService) genVless(inbound *model.Inbound, streamSettings json_ut
|
|||||||
outbound.Mux = json_util.RawMessage(s.mux)
|
outbound.Mux = json_util.RawMessage(s.mux)
|
||||||
}
|
}
|
||||||
outbound.StreamSettings = streamSettings
|
outbound.StreamSettings = streamSettings
|
||||||
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
|
// Add encryption for VLESS outbound from inbound settings
|
||||||
var inboundSettings map[string]any
|
var inboundSettings map[string]any
|
||||||
json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
|
json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
|
||||||
if encryption, ok := inboundSettings["encryption"].(string); ok {
|
encryption, _ := inboundSettings["encryption"].(string)
|
||||||
settings["encryption"] = encryption
|
|
||||||
|
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, "", " ")
|
result, _ := json.MarshalIndent(outbound, "", " ")
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -384,6 +406,49 @@ func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_u
|
|||||||
return result
|
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 {
|
type Outbound struct {
|
||||||
Protocol string `json:"protocol"`
|
Protocol string `json:"protocol"`
|
||||||
Tag string `json:"tag"`
|
Tag string `json:"tag"`
|
||||||
|
|||||||
+903
-637
File diff suppressed because it is too large
Load Diff
@@ -100,37 +100,38 @@ is_port_in_use() {
|
|||||||
|
|
||||||
gen_random_string() {
|
gen_random_string() {
|
||||||
local length="$1"
|
local length="$1"
|
||||||
local random_string=$(LC_ALL=C tr -dc 'a-zA-Z0-9' </dev/urandom | fold -w "$length" | head -n 1)
|
openssl rand -base64 $(( length * 2 )) \
|
||||||
echo "$random_string"
|
| tr -dc 'a-zA-Z0-9' \
|
||||||
|
| head -c "$length"
|
||||||
}
|
}
|
||||||
|
|
||||||
install_base() {
|
install_base() {
|
||||||
echo -e "${green}Updating and install dependency packages...${plain}"
|
echo -e "${green}Updating and install dependency packages...${plain}"
|
||||||
case "${release}" in
|
case "${release}" in
|
||||||
ubuntu | debian | armbian)
|
ubuntu | debian | armbian)
|
||||||
apt-get update >/dev/null 2>&1 && apt-get install -y -q curl tar tzdata socat >/dev/null 2>&1
|
apt-get update >/dev/null 2>&1 && apt-get install -y -q cron curl tar tzdata socat openssl >/dev/null 2>&1
|
||||||
;;
|
;;
|
||||||
fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol)
|
fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol)
|
||||||
dnf -y update >/dev/null 2>&1 && dnf install -y -q curl tar tzdata socat >/dev/null 2>&1
|
dnf -y update >/dev/null 2>&1 && dnf install -y -q cronie curl tar tzdata socat openssl >/dev/null 2>&1
|
||||||
;;
|
;;
|
||||||
centos)
|
centos)
|
||||||
if [[ "${VERSION_ID}" =~ ^7 ]]; then
|
if [[ "${VERSION_ID}" =~ ^7 ]]; then
|
||||||
yum -y update >/dev/null 2>&1 && yum install -y -q curl tar tzdata socat >/dev/null 2>&1
|
yum -y update >/dev/null 2>&1 && yum install -y -q cronie curl tar tzdata socat openssl >/dev/null 2>&1
|
||||||
else
|
else
|
||||||
dnf -y update >/dev/null 2>&1 && dnf install -y -q curl tar tzdata socat >/dev/null 2>&1
|
dnf -y update >/dev/null 2>&1 && dnf install -y -q cronie curl tar tzdata socat openssl >/dev/null 2>&1
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
arch | manjaro | parch)
|
arch | manjaro | parch)
|
||||||
pacman -Syu >/dev/null 2>&1 && pacman -Syu --noconfirm curl tar tzdata socat >/dev/null 2>&1
|
pacman -Syu >/dev/null 2>&1 && pacman -Syu --noconfirm cronie curl tar tzdata socat openssl >/dev/null 2>&1
|
||||||
;;
|
;;
|
||||||
opensuse-tumbleweed | opensuse-leap)
|
opensuse-tumbleweed | opensuse-leap)
|
||||||
zypper refresh >/dev/null 2>&1 && zypper -q install -y curl tar timezone socat >/dev/null 2>&1
|
zypper refresh >/dev/null 2>&1 && zypper -q install -y cron curl tar timezone socat openssl >/dev/null 2>&1
|
||||||
;;
|
;;
|
||||||
alpine)
|
alpine)
|
||||||
apk update >/dev/null 2>&1 && apk add curl tar tzdata socat >/dev/null 2>&1
|
apk update >/dev/null 2>&1 && apk add dcron curl tar tzdata socat openssl>/dev/null 2>&1
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
apt-get update >/dev/null 2>&1 && apt install -y -q curl tar tzdata socat >/dev/null 2>&1
|
apt-get update >/dev/null 2>&1 && apt install -y -q cron curl tar tzdata socat openssl >/dev/null 2>&1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
@@ -401,15 +402,15 @@ ssl_cert_issue() {
|
|||||||
break
|
break
|
||||||
done
|
done
|
||||||
echo -e "${green}Your domain is: ${domain}, checking it...${plain}"
|
echo -e "${green}Your domain is: ${domain}, checking it...${plain}"
|
||||||
|
SSL_ISSUED_DOMAIN="${domain}"
|
||||||
|
|
||||||
# check if there already exists a certificate
|
# detect existing certificate and reuse it if present
|
||||||
local currentCert=$(~/.acme.sh/acme.sh --list | tail -1 | awk '{print $1}')
|
local cert_exists=0
|
||||||
if [ "${currentCert}" == "${domain}" ]; then
|
if ~/.acme.sh/acme.sh --list 2>/dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
|
||||||
local certInfo=$(~/.acme.sh/acme.sh --list)
|
cert_exists=1
|
||||||
echo -e "${red}System already has certificates for this domain. Cannot issue again.${plain}"
|
local certInfo=$(~/.acme.sh/acme.sh --list 2>/dev/null | grep -F "${domain}")
|
||||||
echo -e "${yellow}Current certificate details:${plain}"
|
echo -e "${yellow}Existing certificate found for ${domain}, will reuse it.${plain}"
|
||||||
echo "$certInfo"
|
[[ -n "${certInfo}" ]] && echo "$certInfo"
|
||||||
return 1
|
|
||||||
else
|
else
|
||||||
echo -e "${green}Your domain is ready for issuing certificates now...${plain}"
|
echo -e "${green}Your domain is ready for issuing certificates now...${plain}"
|
||||||
fi
|
fi
|
||||||
@@ -436,6 +437,7 @@ ssl_cert_issue() {
|
|||||||
echo -e "${yellow}Stopping panel temporarily...${plain}"
|
echo -e "${yellow}Stopping panel temporarily...${plain}"
|
||||||
systemctl stop x-ui 2>/dev/null || rc-service x-ui stop 2>/dev/null
|
systemctl stop x-ui 2>/dev/null || rc-service x-ui stop 2>/dev/null
|
||||||
|
|
||||||
|
if [[ ${cert_exists} -eq 0 ]]; then
|
||||||
# issue the certificate
|
# issue the certificate
|
||||||
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force
|
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force
|
||||||
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
|
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
|
||||||
@@ -447,6 +449,9 @@ ssl_cert_issue() {
|
|||||||
else
|
else
|
||||||
echo -e "${green}Issuing certificate succeeded, installing certificates...${plain}"
|
echo -e "${green}Issuing certificate succeeded, installing certificates...${plain}"
|
||||||
fi
|
fi
|
||||||
|
else
|
||||||
|
echo -e "${green}Using existing certificate, installing certificates...${plain}"
|
||||||
|
fi
|
||||||
|
|
||||||
# Setup reload command
|
# Setup reload command
|
||||||
reloadCmd="systemctl restart x-ui || rc-service x-ui restart"
|
reloadCmd="systemctl restart x-ui || rc-service x-ui restart"
|
||||||
@@ -475,17 +480,27 @@ ssl_cert_issue() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# install the certificate
|
# install the certificate
|
||||||
~/.acme.sh/acme.sh --installcert -d ${domain} \
|
local installOutput=""
|
||||||
|
installOutput=$(~/.acme.sh/acme.sh --installcert -d ${domain} \
|
||||||
--key-file /root/cert/${domain}/privkey.pem \
|
--key-file /root/cert/${domain}/privkey.pem \
|
||||||
--fullchain-file /root/cert/${domain}/fullchain.pem --reloadcmd "${reloadCmd}"
|
--fullchain-file /root/cert/${domain}/fullchain.pem --reloadcmd "${reloadCmd}" 2>&1)
|
||||||
|
local installRc=$?
|
||||||
|
echo "${installOutput}"
|
||||||
|
|
||||||
if [ $? -ne 0 ]; then
|
local installWroteFiles=0
|
||||||
|
if echo "${installOutput}" | grep -q "Installing key to:" && echo "${installOutput}" | grep -q "Installing full chain to:"; then
|
||||||
|
installWroteFiles=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -f "/root/cert/${domain}/privkey.pem" && -f "/root/cert/${domain}/fullchain.pem" && ( ${installRc} -eq 0 || ${installWroteFiles} -eq 1 ) ]]; then
|
||||||
|
echo -e "${green}Installing certificate succeeded, enabling auto renew...${plain}"
|
||||||
|
else
|
||||||
echo -e "${red}Installing certificate failed, exiting.${plain}"
|
echo -e "${red}Installing certificate failed, exiting.${plain}"
|
||||||
|
if [[ ${cert_exists} -eq 0 ]]; then
|
||||||
rm -rf ~/.acme.sh/${domain}
|
rm -rf ~/.acme.sh/${domain}
|
||||||
|
fi
|
||||||
systemctl start x-ui 2>/dev/null || rc-service x-ui start 2>/dev/null
|
systemctl start x-ui 2>/dev/null || rc-service x-ui start 2>/dev/null
|
||||||
return 1
|
return 1
|
||||||
else
|
|
||||||
echo -e "${green}Installing certificate succeeded, enabling auto renew...${plain}"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# enable auto-renew
|
# enable auto-renew
|
||||||
@@ -555,9 +570,12 @@ prompt_and_setup_ssl() {
|
|||||||
1)
|
1)
|
||||||
# User chose Let's Encrypt domain option
|
# User chose Let's Encrypt domain option
|
||||||
echo -e "${green}Using Let's Encrypt for domain certificate...${plain}"
|
echo -e "${green}Using Let's Encrypt for domain certificate...${plain}"
|
||||||
ssl_cert_issue
|
if ssl_cert_issue; then
|
||||||
# Extract the domain that was used from the certificate
|
local cert_domain="${SSL_ISSUED_DOMAIN}"
|
||||||
local cert_domain=$(~/.acme.sh/acme.sh --list 2>/dev/null | tail -1 | awk '{print $1}')
|
if [[ -z "${cert_domain}" ]]; then
|
||||||
|
cert_domain=$(~/.acme.sh/acme.sh --list 2>/dev/null | tail -1 | awk '{print $1}')
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ -n "${cert_domain}" ]]; then
|
if [[ -n "${cert_domain}" ]]; then
|
||||||
SSL_HOST="${cert_domain}"
|
SSL_HOST="${cert_domain}"
|
||||||
echo -e "${green}✓ SSL certificate configured successfully with domain: ${cert_domain}${plain}"
|
echo -e "${green}✓ SSL certificate configured successfully with domain: ${cert_domain}${plain}"
|
||||||
@@ -565,6 +583,10 @@ prompt_and_setup_ssl() {
|
|||||||
echo -e "${yellow}SSL setup may have completed, but domain extraction failed${plain}"
|
echo -e "${yellow}SSL setup may have completed, but domain extraction failed${plain}"
|
||||||
SSL_HOST="${server_ip}"
|
SSL_HOST="${server_ip}"
|
||||||
fi
|
fi
|
||||||
|
else
|
||||||
|
echo -e "${red}SSL certificate setup failed for domain mode.${plain}"
|
||||||
|
SSL_HOST="${server_ip}"
|
||||||
|
fi
|
||||||
;;
|
;;
|
||||||
2)
|
2)
|
||||||
# User chose Let's Encrypt IP certificate option
|
# User chose Let's Encrypt IP certificate option
|
||||||
@@ -608,7 +630,7 @@ prompt_and_setup_ssl() {
|
|||||||
|
|
||||||
# 3.1 Request Domain to compose Panel URL later
|
# 3.1 Request Domain to compose Panel URL later
|
||||||
read -rp "Please enter domain name certificate issued for: " custom_domain
|
read -rp "Please enter domain name certificate issued for: " custom_domain
|
||||||
custom_domain="${custom_domain// /}" # Убираем пробелы
|
custom_domain="${custom_domain// /}" # Remove spaces
|
||||||
|
|
||||||
# 3.2 Loop for Certificate Path
|
# 3.2 Loop for Certificate Path
|
||||||
while true; do
|
while true; do
|
||||||
@@ -687,8 +709,11 @@ config_after_update() {
|
|||||||
)
|
)
|
||||||
local server_ip=""
|
local server_ip=""
|
||||||
for ip_address in "${URL_lists[@]}"; do
|
for ip_address in "${URL_lists[@]}"; do
|
||||||
server_ip=$(${curl_bin} -s --max-time 3 "${ip_address}" 2>/dev/null | tr -d '[:space:]')
|
local response=$(curl -s -w "\n%{http_code}" --max-time 3 "${ip_address}" 2>/dev/null)
|
||||||
if [[ -n "${server_ip}" ]]; then
|
local http_code=$(echo "$response" | tail -n1)
|
||||||
|
local ip_result=$(echo "$response" | head -n-1 | tr -d '[:space:]')
|
||||||
|
if [[ "${http_code}" == "200" && -n "${ip_result}" ]]; then
|
||||||
|
server_ip="${ip_result}"
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -7,11 +7,14 @@ import (
|
|||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
"github.com/shirou/gopsutil/v4/net"
|
"github.com/shirou/gopsutil/v4/net"
|
||||||
"golang.org/x/sys/unix"
|
"golang.org/x/sys/unix"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var SIGUSR1 = syscall.SIGUSR1
|
||||||
|
|
||||||
func GetTCPCount() (int, error) {
|
func GetTCPCount() (int, error) {
|
||||||
stats, err := net.Connections("tcp")
|
stats, err := net.Connections("tcp")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -12,8 +12,11 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"syscall"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var SIGUSR1 = syscall.SIGUSR1
|
||||||
|
|
||||||
func getLinesNum(filename string) (int, error) {
|
func getLinesNum(filename string) (int, error) {
|
||||||
file, err := os.Open(filename)
|
file, err := os.Open(filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import (
|
|||||||
"github.com/shirou/gopsutil/v4/net"
|
"github.com/shirou/gopsutil/v4/net"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var SIGUSR1 = syscall.Signal(0)
|
||||||
|
|
||||||
// GetConnectionCount returns the number of active connections for the specified protocol ("tcp" or "udp").
|
// GetConnectionCount returns the number of active connections for the specified protocol ("tcp" or "udp").
|
||||||
func GetConnectionCount(proto string) (int, error) {
|
func GetConnectionCount(proto string) (int, error) {
|
||||||
if proto != "tcp" && proto != "udp" {
|
if proto != "tcp" && proto != "udp" {
|
||||||
|
|||||||
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();
|
return this.expiryTime < new Date().getTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
invalidateCache() {
|
||||||
|
this._cachedInbound = null;
|
||||||
|
this._clientStatsMap = null;
|
||||||
|
}
|
||||||
|
|
||||||
toInbound() {
|
toInbound() {
|
||||||
|
if (this._cachedInbound) {
|
||||||
|
return this._cachedInbound;
|
||||||
|
}
|
||||||
|
|
||||||
let settings = {};
|
let settings = {};
|
||||||
if (!ObjectUtil.isEmpty(this.settings)) {
|
if (!ObjectUtil.isEmpty(this.settings)) {
|
||||||
settings = JSON.parse(this.settings);
|
settings = JSON.parse(this.settings);
|
||||||
@@ -116,7 +125,21 @@ class DBInbound {
|
|||||||
sniffing: sniffing,
|
sniffing: sniffing,
|
||||||
clientStats: this.clientStats,
|
clientStats: this.clientStats,
|
||||||
};
|
};
|
||||||
return Inbound.fromJson(config);
|
|
||||||
|
this._cachedInbound = Inbound.fromJson(config);
|
||||||
|
return this._cachedInbound;
|
||||||
|
}
|
||||||
|
|
||||||
|
getClientStats(email) {
|
||||||
|
if (!this._clientStatsMap) {
|
||||||
|
this._clientStatsMap = new Map();
|
||||||
|
if (this.clientStats && Array.isArray(this.clientStats)) {
|
||||||
|
for (const stats of this.clientStats) {
|
||||||
|
this._clientStatsMap.set(stats.email, stats);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this._clientStatsMap.get(email);
|
||||||
}
|
}
|
||||||
|
|
||||||
isMultiUser() {
|
isMultiUser() {
|
||||||
@@ -124,6 +147,7 @@ class DBInbound {
|
|||||||
case Protocols.VMESS:
|
case Protocols.VMESS:
|
||||||
case Protocols.VLESS:
|
case Protocols.VLESS:
|
||||||
case Protocols.TROJAN:
|
case Protocols.TROJAN:
|
||||||
|
case Protocols.HYSTERIA:
|
||||||
return true;
|
return true;
|
||||||
case Protocols.SHADOWSOCKS:
|
case Protocols.SHADOWSOCKS:
|
||||||
return this.toInbound().isSSMultiUser;
|
return this.toInbound().isSSMultiUser;
|
||||||
@@ -138,6 +162,7 @@ class DBInbound {
|
|||||||
case Protocols.VLESS:
|
case Protocols.VLESS:
|
||||||
case Protocols.TROJAN:
|
case Protocols.TROJAN:
|
||||||
case Protocols.SHADOWSOCKS:
|
case Protocols.SHADOWSOCKS:
|
||||||
|
case Protocols.HYSTERIA:
|
||||||
return true;
|
return true;
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
+790
-348
File diff suppressed because it is too large
Load Diff
+418
-64
@@ -97,6 +97,74 @@ const Address_Port_Strategy = {
|
|||||||
TxtPortAndAddress: "txtportandaddress"
|
TxtPortAndAddress: "txtportandaddress"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DNSRuleActions = ['direct', 'drop', 'reject', 'hijack'];
|
||||||
|
|
||||||
|
function normalizeDNSRuleField(value) {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(item => item.toString().trim()).filter(item => item.length > 0).join(',');
|
||||||
|
}
|
||||||
|
return value.toString().trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDNSRuleAction(action) {
|
||||||
|
action = ObjectUtil.isEmpty(action) ? 'direct' : action.toString().toLowerCase().trim();
|
||||||
|
return DNSRuleActions.includes(action) ? action : 'direct';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLegacyDNSBlockTypes(blockTypes) {
|
||||||
|
if (blockTypes === null || blockTypes === undefined || blockTypes === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(blockTypes)) {
|
||||||
|
return blockTypes
|
||||||
|
.map(item => Number(item))
|
||||||
|
.filter(item => Number.isInteger(item) && item >= 0 && item <= 65535);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof blockTypes === 'number') {
|
||||||
|
return Number.isInteger(blockTypes) && blockTypes >= 0 && blockTypes <= 65535 ? [blockTypes] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return blockTypes
|
||||||
|
.toString()
|
||||||
|
.split(',')
|
||||||
|
.map(item => item.trim())
|
||||||
|
.filter(item => /^\d+$/.test(item))
|
||||||
|
.map(item => Number(item))
|
||||||
|
.filter(item => item >= 0 && item <= 65535);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLegacyDNSRules(nonIPQuery, blockTypes) {
|
||||||
|
const mode = ['reject', 'drop', 'skip'].includes(nonIPQuery) ? nonIPQuery : 'reject';
|
||||||
|
const rules = [];
|
||||||
|
const parsedBlockTypes = parseLegacyDNSBlockTypes(blockTypes);
|
||||||
|
|
||||||
|
if (parsedBlockTypes.length > 0) {
|
||||||
|
rules.push(new Outbound.DNSRule(mode === 'reject' ? 'reject' : 'drop', parsedBlockTypes.join(',')));
|
||||||
|
}
|
||||||
|
|
||||||
|
rules.push(new Outbound.DNSRule('hijack', '1,28'));
|
||||||
|
rules.push(new Outbound.DNSRule(mode === 'skip' ? 'direct' : mode));
|
||||||
|
|
||||||
|
return rules;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDNSRulesFromJson(json = {}) {
|
||||||
|
if (Array.isArray(json.rules) && json.rules.length > 0) {
|
||||||
|
return json.rules.map(rule => Outbound.DNSRule.fromJson(rule));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (json.nonIPQuery !== undefined || json.blockTypes !== undefined) {
|
||||||
|
return buildLegacyDNSRules(json.nonIPQuery, json.blockTypes);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
Object.freeze(Protocols);
|
Object.freeze(Protocols);
|
||||||
Object.freeze(SSMethods);
|
Object.freeze(SSMethods);
|
||||||
Object.freeze(TLS_FLOW_CONTROL);
|
Object.freeze(TLS_FLOW_CONTROL);
|
||||||
@@ -107,6 +175,7 @@ Object.freeze(WireguardDomainStrategy);
|
|||||||
Object.freeze(USERS_SECURITY);
|
Object.freeze(USERS_SECURITY);
|
||||||
Object.freeze(MODE_OPTION);
|
Object.freeze(MODE_OPTION);
|
||||||
Object.freeze(Address_Port_Strategy);
|
Object.freeze(Address_Port_Strategy);
|
||||||
|
Object.freeze(DNSRuleActions);
|
||||||
|
|
||||||
class CommonClass {
|
class CommonClass {
|
||||||
|
|
||||||
@@ -169,18 +238,16 @@ class KcpStreamSettings extends CommonClass {
|
|||||||
tti = 20,
|
tti = 20,
|
||||||
uplinkCapacity = 5,
|
uplinkCapacity = 5,
|
||||||
downlinkCapacity = 20,
|
downlinkCapacity = 20,
|
||||||
congestion = false,
|
cwndMultiplier = 1,
|
||||||
readBufferSize = 1,
|
maxSendingWindow = 1350,
|
||||||
writeBufferSize = 1,
|
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
this.mtu = mtu;
|
this.mtu = mtu;
|
||||||
this.tti = tti;
|
this.tti = tti;
|
||||||
this.upCap = uplinkCapacity;
|
this.upCap = uplinkCapacity;
|
||||||
this.downCap = downlinkCapacity;
|
this.downCap = downlinkCapacity;
|
||||||
this.congestion = congestion;
|
this.cwndMultiplier = cwndMultiplier;
|
||||||
this.readBuffer = readBufferSize;
|
this.maxSendingWindow = maxSendingWindow;
|
||||||
this.writeBuffer = writeBufferSize;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static fromJson(json = {}) {
|
static fromJson(json = {}) {
|
||||||
@@ -189,9 +256,8 @@ class KcpStreamSettings extends CommonClass {
|
|||||||
json.tti,
|
json.tti,
|
||||||
json.uplinkCapacity,
|
json.uplinkCapacity,
|
||||||
json.downlinkCapacity,
|
json.downlinkCapacity,
|
||||||
json.congestion,
|
json.cwndMultiplier,
|
||||||
json.readBufferSize,
|
json.maxSendingWindow,
|
||||||
json.writeBufferSize,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,9 +267,8 @@ class KcpStreamSettings extends CommonClass {
|
|||||||
tti: this.tti,
|
tti: this.tti,
|
||||||
uplinkCapacity: this.upCap,
|
uplinkCapacity: this.upCap,
|
||||||
downlinkCapacity: this.downCap,
|
downlinkCapacity: this.downCap,
|
||||||
congestion: this.congestion,
|
cwndMultiplier: this.cwndMultiplier,
|
||||||
readBufferSize: this.readBuffer,
|
maxSendingWindow: this.maxSendingWindow,
|
||||||
writeBufferSize: this.writeBuffer,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -345,16 +410,14 @@ class TlsStreamSettings extends CommonClass {
|
|||||||
serverName = '',
|
serverName = '',
|
||||||
alpn = [],
|
alpn = [],
|
||||||
fingerprint = '',
|
fingerprint = '',
|
||||||
allowInsecure = false,
|
|
||||||
echConfigList = '',
|
echConfigList = '',
|
||||||
verifyPeerCertByName = 'cloudflare-dns.com',
|
verifyPeerCertByName = '',
|
||||||
pinnedPeerCertSha256 = '',
|
pinnedPeerCertSha256 = '',
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
this.serverName = serverName;
|
this.serverName = serverName;
|
||||||
this.alpn = alpn;
|
this.alpn = alpn;
|
||||||
this.fingerprint = fingerprint;
|
this.fingerprint = fingerprint;
|
||||||
this.allowInsecure = allowInsecure;
|
|
||||||
this.echConfigList = echConfigList;
|
this.echConfigList = echConfigList;
|
||||||
this.verifyPeerCertByName = verifyPeerCertByName;
|
this.verifyPeerCertByName = verifyPeerCertByName;
|
||||||
this.pinnedPeerCertSha256 = pinnedPeerCertSha256;
|
this.pinnedPeerCertSha256 = pinnedPeerCertSha256;
|
||||||
@@ -365,7 +428,6 @@ class TlsStreamSettings extends CommonClass {
|
|||||||
json.serverName,
|
json.serverName,
|
||||||
json.alpn,
|
json.alpn,
|
||||||
json.fingerprint,
|
json.fingerprint,
|
||||||
json.allowInsecure,
|
|
||||||
json.echConfigList,
|
json.echConfigList,
|
||||||
json.verifyPeerCertByName,
|
json.verifyPeerCertByName,
|
||||||
json.pinnedPeerCertSha256,
|
json.pinnedPeerCertSha256,
|
||||||
@@ -377,7 +439,6 @@ class TlsStreamSettings extends CommonClass {
|
|||||||
serverName: this.serverName,
|
serverName: this.serverName,
|
||||||
alpn: this.alpn,
|
alpn: this.alpn,
|
||||||
fingerprint: this.fingerprint,
|
fingerprint: this.fingerprint,
|
||||||
allowInsecure: this.allowInsecure,
|
|
||||||
echConfigList: this.echConfigList,
|
echConfigList: this.echConfigList,
|
||||||
verifyPeerCertByName: this.verifyPeerCertByName,
|
verifyPeerCertByName: this.verifyPeerCertByName,
|
||||||
pinnedPeerCertSha256: this.pinnedPeerCertSha256
|
pinnedPeerCertSha256: this.pinnedPeerCertSha256
|
||||||
@@ -568,7 +629,7 @@ class SockoptStreamSettings extends CommonClass {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class FinalMask extends CommonClass {
|
class UdpMask extends CommonClass {
|
||||||
constructor(type = 'salamander', settings = {}) {
|
constructor(type = 'salamander', settings = {}) {
|
||||||
super();
|
super();
|
||||||
this.type = type;
|
this.type = type;
|
||||||
@@ -581,8 +642,11 @@ class FinalMask extends CommonClass {
|
|||||||
case 'mkcp-aes128gcm':
|
case 'mkcp-aes128gcm':
|
||||||
return { password: settings.password || '' };
|
return { password: settings.password || '' };
|
||||||
case 'header-dns':
|
case 'header-dns':
|
||||||
case 'xdns':
|
|
||||||
return { domain: settings.domain || '' };
|
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 'mkcp-original':
|
||||||
case 'header-dtls':
|
case 'header-dtls':
|
||||||
case 'header-srtp':
|
case 'header-srtp':
|
||||||
@@ -590,25 +654,216 @@ class FinalMask extends CommonClass {
|
|||||||
case 'header-wechat':
|
case 'header-wechat':
|
||||||
case 'header-wireguard':
|
case 'header-wireguard':
|
||||||
return {}; // No settings needed
|
return {}; // No settings needed
|
||||||
|
case 'header-custom':
|
||||||
|
return {
|
||||||
|
client: Array.isArray(settings.client) ? settings.client : [],
|
||||||
|
server: Array.isArray(settings.server) ? settings.server : [],
|
||||||
|
};
|
||||||
|
case 'noise':
|
||||||
|
return {
|
||||||
|
reset: settings.reset ?? 0,
|
||||||
|
noise: Array.isArray(settings.noise) ? settings.noise : [],
|
||||||
|
};
|
||||||
|
case 'sudoku':
|
||||||
|
return {
|
||||||
|
ascii: settings.ascii || '',
|
||||||
|
customTable: settings.customTable || '',
|
||||||
|
customTables: Array.isArray(settings.customTables) ? settings.customTables : [],
|
||||||
|
paddingMin: settings.paddingMin ?? 0,
|
||||||
|
paddingMax: settings.paddingMax ?? 0
|
||||||
|
};
|
||||||
default:
|
default:
|
||||||
return settings;
|
return settings;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static fromJson(json = {}) {
|
static fromJson(json = {}) {
|
||||||
return new FinalMask(
|
return new UdpMask(
|
||||||
json.type || 'salamander',
|
json.type || 'salamander',
|
||||||
json.settings || {}
|
json.settings || {}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
toJson() {
|
toJson() {
|
||||||
const result = {
|
const cleanItem = item => {
|
||||||
type: this.type
|
const out = { ...item };
|
||||||
|
if (out.type === 'array') {
|
||||||
|
delete out.packet;
|
||||||
|
} else {
|
||||||
|
delete out.rand;
|
||||||
|
delete out.randRange;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
};
|
};
|
||||||
// Only include settings if they exist and are not empty
|
|
||||||
if (this.settings && Object.keys(this.settings).length > 0) {
|
let settings = this.settings;
|
||||||
result.settings = this.settings;
|
if (this.type === 'noise' && settings && Array.isArray(settings.noise)) {
|
||||||
|
settings = { ...settings, noise: settings.noise.map(cleanItem) };
|
||||||
|
} else if (this.type === 'header-custom' && settings) {
|
||||||
|
settings = {
|
||||||
|
...settings,
|
||||||
|
client: Array.isArray(settings.client) ? settings.client.map(cleanItem) : settings.client,
|
||||||
|
server: Array.isArray(settings.server) ? settings.server.map(cleanItem) : settings.server,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: this.type,
|
||||||
|
settings: (settings && Object.keys(settings).length > 0) ? settings : undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TcpMask extends CommonClass {
|
||||||
|
constructor(type = 'fragment', settings = {}) {
|
||||||
|
super();
|
||||||
|
this.type = type;
|
||||||
|
this.settings = this._getDefaultSettings(type, settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
_getDefaultSettings(type, settings = {}) {
|
||||||
|
switch (type) {
|
||||||
|
case 'fragment':
|
||||||
|
return {
|
||||||
|
packets: settings.packets ?? 'tlshello',
|
||||||
|
length: settings.length ?? '',
|
||||||
|
delay: settings.delay ?? '',
|
||||||
|
maxSplit: settings.maxSplit ?? '',
|
||||||
|
};
|
||||||
|
case 'sudoku':
|
||||||
|
return {
|
||||||
|
password: settings.password ?? '',
|
||||||
|
ascii: settings.ascii ?? '',
|
||||||
|
customTable: settings.customTable ?? '',
|
||||||
|
customTables: Array.isArray(settings.customTables) ? settings.customTables : [],
|
||||||
|
paddingMin: settings.paddingMin ?? 0,
|
||||||
|
paddingMax: settings.paddingMax ?? 0,
|
||||||
|
};
|
||||||
|
case 'header-custom':
|
||||||
|
return {
|
||||||
|
clients: Array.isArray(settings.clients) ? settings.clients : [],
|
||||||
|
servers: Array.isArray(settings.servers) ? settings.servers : [],
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromJson(json = {}) {
|
||||||
|
return new TcpMask(
|
||||||
|
json.type || 'fragment',
|
||||||
|
json.settings || {}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
toJson() {
|
||||||
|
const cleanItem = item => {
|
||||||
|
const out = { ...item };
|
||||||
|
if (out.type === 'array') {
|
||||||
|
delete out.packet;
|
||||||
|
} else {
|
||||||
|
delete out.rand;
|
||||||
|
delete out.randRange;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
let settings = this.settings;
|
||||||
|
if (this.type === 'header-custom' && settings) {
|
||||||
|
const cleanGroup = group => Array.isArray(group) ? group.map(cleanItem) : group;
|
||||||
|
settings = {
|
||||||
|
...settings,
|
||||||
|
clients: Array.isArray(settings.clients) ? settings.clients.map(cleanGroup) : settings.clients,
|
||||||
|
servers: Array.isArray(settings.servers) ? settings.servers.map(cleanGroup) : settings.servers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: this.type,
|
||||||
|
settings: (settings && Object.keys(settings).length > 0) ? settings : undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class QuicParams extends CommonClass {
|
||||||
|
constructor(
|
||||||
|
congestion = 'bbr',
|
||||||
|
debug = false,
|
||||||
|
brutalUp = '',
|
||||||
|
brutalDown = '',
|
||||||
|
udpHop = undefined,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
this.congestion = congestion;
|
||||||
|
this.debug = debug;
|
||||||
|
this.brutalUp = brutalUp;
|
||||||
|
this.brutalDown = brutalDown;
|
||||||
|
this.udpHop = udpHop;
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasUdpHop() {
|
||||||
|
return this.udpHop != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
set hasUdpHop(value) {
|
||||||
|
this.udpHop = value ? (this.udpHop || { ports: '20000-50000', interval: '5-10' }) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromJson(json = {}) {
|
||||||
|
if (!json || Object.keys(json).length === 0) return undefined;
|
||||||
|
return new QuicParams(
|
||||||
|
json.congestion,
|
||||||
|
json.debug,
|
||||||
|
json.brutalUp,
|
||||||
|
json.brutalDown,
|
||||||
|
json.udpHop ? { ports: json.udpHop.ports, interval: json.udpHop.interval } : undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
toJson() {
|
||||||
|
const result = { congestion: this.congestion };
|
||||||
|
if (this.debug) result.debug = this.debug;
|
||||||
|
if (this.brutalUp) result.brutalUp = this.brutalUp;
|
||||||
|
if (this.brutalDown) result.brutalDown = this.brutalDown;
|
||||||
|
if (this.udpHop) result.udpHop = { ports: this.udpHop.ports, interval: this.udpHop.interval };
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FinalMaskStreamSettings extends CommonClass {
|
||||||
|
constructor(tcp = [], udp = [], quicParams = undefined) {
|
||||||
|
super();
|
||||||
|
this.tcp = Array.isArray(tcp) ? tcp.map(t => t instanceof TcpMask ? t : new TcpMask(t.type, t.settings)) : [];
|
||||||
|
this.udp = Array.isArray(udp) ? udp.map(u => new UdpMask(u.type, u.settings)) : [new UdpMask(udp.type, udp.settings)];
|
||||||
|
this.quicParams = quicParams instanceof QuicParams ? quicParams : (quicParams ? QuicParams.fromJson(quicParams) : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
get enableQuicParams() {
|
||||||
|
return this.quicParams != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
set enableQuicParams(value) {
|
||||||
|
this.quicParams = value ? (this.quicParams || new QuicParams()) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromJson(json = {}) {
|
||||||
|
return new FinalMaskStreamSettings(
|
||||||
|
json.tcp || [],
|
||||||
|
json.udp || [],
|
||||||
|
json.quicParams ? QuicParams.fromJson(json.quicParams) : undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
toJson() {
|
||||||
|
const result = {};
|
||||||
|
if (this.tcp && this.tcp.length > 0) {
|
||||||
|
result.tcp = this.tcp.map(t => t.toJson());
|
||||||
|
}
|
||||||
|
if (this.udp && this.udp.length > 0) {
|
||||||
|
result.udp = this.udp.map(udp => udp.toJson());
|
||||||
|
}
|
||||||
|
if (this.quicParams) {
|
||||||
|
result.quicParams = this.quicParams.toJson();
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -627,7 +882,7 @@ class StreamSettings extends CommonClass {
|
|||||||
httpupgradeSettings = new HttpUpgradeStreamSettings(),
|
httpupgradeSettings = new HttpUpgradeStreamSettings(),
|
||||||
xhttpSettings = new xHTTPStreamSettings(),
|
xhttpSettings = new xHTTPStreamSettings(),
|
||||||
hysteriaSettings = new HysteriaStreamSettings(),
|
hysteriaSettings = new HysteriaStreamSettings(),
|
||||||
finalmask = { udp: [] },
|
finalmask = new FinalMaskStreamSettings(),
|
||||||
sockopt = undefined,
|
sockopt = undefined,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
@@ -646,11 +901,18 @@ class StreamSettings extends CommonClass {
|
|||||||
this.sockopt = sockopt;
|
this.sockopt = sockopt;
|
||||||
}
|
}
|
||||||
|
|
||||||
addUdpMask(type = 'salamander') {
|
addTcpMask(type = 'fragment') {
|
||||||
if (!this.finalmask.udp) {
|
this.finalmask.tcp.push(new TcpMask(type));
|
||||||
this.finalmask.udp = [];
|
|
||||||
}
|
}
|
||||||
this.finalmask.udp.push(new FinalMask(type));
|
|
||||||
|
delTcpMask(index) {
|
||||||
|
if (this.finalmask.tcp) {
|
||||||
|
this.finalmask.tcp.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addUdpMask(type = 'salamander') {
|
||||||
|
this.finalmask.udp.push(new UdpMask(type));
|
||||||
}
|
}
|
||||||
|
|
||||||
delUdpMask(index) {
|
delUdpMask(index) {
|
||||||
@@ -659,6 +921,13 @@ class StreamSettings extends CommonClass {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get hasFinalMask() {
|
||||||
|
const hasTcp = this.finalmask.tcp && this.finalmask.tcp.length > 0;
|
||||||
|
const hasUdp = this.finalmask.udp && this.finalmask.udp.length > 0;
|
||||||
|
const hasQuicParams = this.finalmask.quicParams != null;
|
||||||
|
return hasTcp || hasUdp || hasQuicParams;
|
||||||
|
}
|
||||||
|
|
||||||
get isTls() {
|
get isTls() {
|
||||||
return this.security === 'tls';
|
return this.security === 'tls';
|
||||||
}
|
}
|
||||||
@@ -676,16 +945,6 @@ class StreamSettings extends CommonClass {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static fromJson(json = {}) {
|
static fromJson(json = {}) {
|
||||||
let finalmask = { udp: [] };
|
|
||||||
if (json.finalmask) {
|
|
||||||
if (Array.isArray(json.finalmask)) {
|
|
||||||
// Legacy format: direct array (backward compatibility)
|
|
||||||
finalmask.udp = json.finalmask.map(mask => FinalMask.fromJson(mask));
|
|
||||||
} else if (json.finalmask.udp) {
|
|
||||||
// New format: object with udp array
|
|
||||||
finalmask.udp = json.finalmask.udp.map(mask => FinalMask.fromJson(mask));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new StreamSettings(
|
return new StreamSettings(
|
||||||
json.network,
|
json.network,
|
||||||
json.security,
|
json.security,
|
||||||
@@ -698,7 +957,7 @@ class StreamSettings extends CommonClass {
|
|||||||
HttpUpgradeStreamSettings.fromJson(json.httpupgradeSettings),
|
HttpUpgradeStreamSettings.fromJson(json.httpupgradeSettings),
|
||||||
xHTTPStreamSettings.fromJson(json.xhttpSettings),
|
xHTTPStreamSettings.fromJson(json.xhttpSettings),
|
||||||
HysteriaStreamSettings.fromJson(json.hysteriaSettings),
|
HysteriaStreamSettings.fromJson(json.hysteriaSettings),
|
||||||
finalmask,
|
FinalMaskStreamSettings.fromJson(json.finalmask),
|
||||||
SockoptStreamSettings.fromJson(json.sockopt),
|
SockoptStreamSettings.fromJson(json.sockopt),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -717,9 +976,7 @@ class StreamSettings extends CommonClass {
|
|||||||
httpupgradeSettings: network === 'httpupgrade' ? this.httpupgrade.toJson() : undefined,
|
httpupgradeSettings: network === 'httpupgrade' ? this.httpupgrade.toJson() : undefined,
|
||||||
xhttpSettings: network === 'xhttp' ? this.xhttp.toJson() : undefined,
|
xhttpSettings: network === 'xhttp' ? this.xhttp.toJson() : undefined,
|
||||||
hysteriaSettings: network === 'hysteria' ? this.hysteria.toJson() : undefined,
|
hysteriaSettings: network === 'hysteria' ? this.hysteria.toJson() : undefined,
|
||||||
finalmask: (this.finalmask.udp && this.finalmask.udp.length > 0) ? {
|
finalmask: this.hasFinalMask ? this.finalmask.toJson() : undefined,
|
||||||
udp: this.finalmask.udp.map(mask => mask.toJson())
|
|
||||||
} : undefined,
|
|
||||||
sockopt: this.sockopt != undefined ? this.sockopt.toJson() : undefined,
|
sockopt: this.sockopt != undefined ? this.sockopt.toJson() : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -783,8 +1040,8 @@ class Outbound extends CommonClass {
|
|||||||
}
|
}
|
||||||
|
|
||||||
canEnableTls() {
|
canEnableTls() {
|
||||||
if (![Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks, Protocols.Hysteria].includes(this.protocol)) return false;
|
if (this.protocol === Protocols.Hysteria) return true;
|
||||||
if (this.protocol === Protocols.Hysteria) return this.stream.network === 'hysteria';
|
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);
|
return ["tcp", "ws", "http", "grpc", "httpupgrade", "xhttp"].includes(this.stream.network);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -919,6 +1176,10 @@ class Outbound extends CommonClass {
|
|||||||
stream.kcp = new KcpStreamSettings();
|
stream.kcp = new KcpStreamSettings();
|
||||||
stream.type = json.type;
|
stream.type = json.type;
|
||||||
stream.seed = json.path;
|
stream.seed = json.path;
|
||||||
|
const mtu = Number(json.mtu);
|
||||||
|
if (Number.isFinite(mtu) && mtu > 0) stream.kcp.mtu = mtu;
|
||||||
|
const tti = Number(json.tti);
|
||||||
|
if (Number.isFinite(tti) && tti > 0) stream.kcp.tti = tti;
|
||||||
} else if (network === 'ws') {
|
} else if (network === 'ws') {
|
||||||
stream.ws = new WsStreamSettings(json.path, json.host);
|
stream.ws = new WsStreamSettings(json.path, json.host);
|
||||||
} else if (network === 'grpc') {
|
} else if (network === 'grpc') {
|
||||||
@@ -926,15 +1187,20 @@ class Outbound extends CommonClass {
|
|||||||
} else if (network === 'httpupgrade') {
|
} else if (network === 'httpupgrade') {
|
||||||
stream.httpupgrade = new HttpUpgradeStreamSettings(json.path, json.host);
|
stream.httpupgrade = new HttpUpgradeStreamSettings(json.path, json.host);
|
||||||
} else if (network === 'xhttp') {
|
} else if (network === 'xhttp') {
|
||||||
stream.xhttp = new xHTTPStreamSettings(json.path, json.host, json.mode);
|
// xHTTPStreamSettings positional args are (path, host, headers, ..., mode);
|
||||||
|
// passing `json.mode` as the 3rd argument used to land in the `headers`
|
||||||
|
// slot, dropping the mode on the floor. Build the object and set mode
|
||||||
|
// explicitly to avoid that.
|
||||||
|
const xh = new xHTTPStreamSettings(json.path, json.host);
|
||||||
|
if (json.mode) xh.mode = json.mode;
|
||||||
|
stream.xhttp = xh;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (json.tls && json.tls == 'tls') {
|
if (json.tls && json.tls == 'tls') {
|
||||||
stream.tls = new TlsStreamSettings(
|
stream.tls = new TlsStreamSettings(
|
||||||
json.sni,
|
json.sni,
|
||||||
json.alpn ? json.alpn.split(',') : [],
|
json.alpn ? json.alpn.split(',') : [],
|
||||||
json.fp,
|
json.fp);
|
||||||
json.allowInsecure);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const port = json.port * 1;
|
const port = json.port * 1;
|
||||||
@@ -951,6 +1217,7 @@ class Outbound extends CommonClass {
|
|||||||
let headerType = url.searchParams.get('headerType') ?? undefined;
|
let headerType = url.searchParams.get('headerType') ?? undefined;
|
||||||
let host = url.searchParams.get('host') ?? undefined;
|
let host = url.searchParams.get('host') ?? undefined;
|
||||||
let path = url.searchParams.get('path') ?? undefined;
|
let path = url.searchParams.get('path') ?? undefined;
|
||||||
|
let seed = url.searchParams.get('seed') ?? path ?? undefined;
|
||||||
let mode = url.searchParams.get('mode') ?? undefined;
|
let mode = url.searchParams.get('mode') ?? undefined;
|
||||||
|
|
||||||
if (type === 'tcp' || type === 'none') {
|
if (type === 'tcp' || type === 'none') {
|
||||||
@@ -958,7 +1225,11 @@ class Outbound extends CommonClass {
|
|||||||
} else if (type === 'kcp') {
|
} else if (type === 'kcp') {
|
||||||
stream.kcp = new KcpStreamSettings();
|
stream.kcp = new KcpStreamSettings();
|
||||||
stream.kcp.type = headerType ?? 'none';
|
stream.kcp.type = headerType ?? 'none';
|
||||||
stream.kcp.seed = path;
|
stream.kcp.seed = seed;
|
||||||
|
const mtu = Number(url.searchParams.get('mtu'));
|
||||||
|
if (Number.isFinite(mtu) && mtu > 0) stream.kcp.mtu = mtu;
|
||||||
|
const tti = Number(url.searchParams.get('tti'));
|
||||||
|
if (Number.isFinite(tti) && tti > 0) stream.kcp.tti = tti;
|
||||||
} else if (type === 'ws') {
|
} else if (type === 'ws') {
|
||||||
stream.ws = new WsStreamSettings(path, host);
|
stream.ws = new WsStreamSettings(path, host);
|
||||||
} else if (type === 'grpc') {
|
} else if (type === 'grpc') {
|
||||||
@@ -969,16 +1240,33 @@ class Outbound extends CommonClass {
|
|||||||
} else if (type === 'httpupgrade') {
|
} else if (type === 'httpupgrade') {
|
||||||
stream.httpupgrade = new HttpUpgradeStreamSettings(path, host);
|
stream.httpupgrade = new HttpUpgradeStreamSettings(path, host);
|
||||||
} else if (type === 'xhttp') {
|
} else if (type === 'xhttp') {
|
||||||
stream.xhttp = new xHTTPStreamSettings(path, host, mode);
|
// Same positional bug as in the VMess-JSON branch above:
|
||||||
|
// passing `mode` as the 3rd positional arg put it into the
|
||||||
|
// `headers` slot. Build explicitly instead.
|
||||||
|
const xh = new xHTTPStreamSettings(path, host);
|
||||||
|
if (mode) xh.mode = mode;
|
||||||
|
const xpb = url.searchParams.get('x_padding_bytes');
|
||||||
|
if (xpb) xh.xPaddingBytes = xpb;
|
||||||
|
const extraRaw = url.searchParams.get('extra');
|
||||||
|
if (extraRaw) {
|
||||||
|
try {
|
||||||
|
const extra = JSON.parse(extraRaw);
|
||||||
|
if (typeof extra.xPaddingBytes === 'string' && extra.xPaddingBytes) xh.xPaddingBytes = extra.xPaddingBytes;
|
||||||
|
if (extra.xPaddingObfsMode === true) xh.xPaddingObfsMode = true;
|
||||||
|
["xPaddingKey", "xPaddingHeader", "xPaddingPlacement", "xPaddingMethod"].forEach(k => {
|
||||||
|
if (typeof extra[k] === 'string' && extra[k]) xh[k] = extra[k];
|
||||||
|
});
|
||||||
|
} catch (_) { /* ignore malformed extra */ }
|
||||||
|
}
|
||||||
|
stream.xhttp = xh;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (security == 'tls') {
|
if (security == 'tls') {
|
||||||
let fp = url.searchParams.get('fp') ?? 'none';
|
let fp = url.searchParams.get('fp') ?? 'none';
|
||||||
let alpn = url.searchParams.get('alpn');
|
let alpn = url.searchParams.get('alpn');
|
||||||
let allowInsecure = url.searchParams.get('allowInsecure');
|
|
||||||
let sni = url.searchParams.get('sni') ?? '';
|
let sni = url.searchParams.get('sni') ?? '';
|
||||||
let ech = url.searchParams.get('ech') ?? '';
|
let ech = url.searchParams.get('ech') ?? '';
|
||||||
stream.tls = new TlsStreamSettings(sni, alpn ? alpn.split(',') : [], fp, allowInsecure == 1, ech);
|
stream.tls = new TlsStreamSettings(sni, alpn ? alpn.split(',') : [], fp, ech);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (security == 'reality') {
|
if (security == 'reality') {
|
||||||
@@ -1136,13 +1424,15 @@ Outbound.FreedomSettings = class extends CommonClass {
|
|||||||
domainStrategy = '',
|
domainStrategy = '',
|
||||||
redirect = '',
|
redirect = '',
|
||||||
fragment = {},
|
fragment = {},
|
||||||
noises = []
|
noises = [],
|
||||||
|
ipsBlocked = [],
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
this.domainStrategy = domainStrategy;
|
this.domainStrategy = domainStrategy;
|
||||||
this.redirect = redirect;
|
this.redirect = redirect;
|
||||||
this.fragment = fragment;
|
this.fragment = fragment || {};
|
||||||
this.noises = noises;
|
this.noises = Array.isArray(noises) ? noises : [];
|
||||||
|
this.ipsBlocked = Array.isArray(ipsBlocked) ? ipsBlocked : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
addNoise() {
|
addNoise() {
|
||||||
@@ -1157,8 +1447,9 @@ Outbound.FreedomSettings = class extends CommonClass {
|
|||||||
return new Outbound.FreedomSettings(
|
return new Outbound.FreedomSettings(
|
||||||
json.domainStrategy,
|
json.domainStrategy,
|
||||||
json.redirect,
|
json.redirect,
|
||||||
json.fragment ? Outbound.FreedomSettings.Fragment.fromJson(json.fragment) : undefined,
|
json.fragment ? Outbound.FreedomSettings.Fragment.fromJson(json.fragment) : {},
|
||||||
json.noises ? json.noises.map(noise => Outbound.FreedomSettings.Noise.fromJson(noise)) : undefined,
|
json.noises ? json.noises.map(noise => Outbound.FreedomSettings.Noise.fromJson(noise)) : [],
|
||||||
|
json.ipsBlocked || [],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1168,6 +1459,7 @@ Outbound.FreedomSettings = class extends CommonClass {
|
|||||||
redirect: ObjectUtil.isEmpty(this.redirect) ? undefined : this.redirect,
|
redirect: ObjectUtil.isEmpty(this.redirect) ? undefined : this.redirect,
|
||||||
fragment: Object.keys(this.fragment).length === 0 ? undefined : this.fragment,
|
fragment: Object.keys(this.fragment).length === 0 ? undefined : this.fragment,
|
||||||
noises: this.noises.length === 0 ? undefined : Outbound.FreedomSettings.Noise.toJsonArray(this.noises),
|
noises: this.noises.length === 0 ? undefined : Outbound.FreedomSettings.Noise.toJsonArray(this.noises),
|
||||||
|
ipsBlocked: this.ipsBlocked.length === 0 ? undefined : this.ipsBlocked,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1247,20 +1539,69 @@ Outbound.BlackholeSettings = class extends CommonClass {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Outbound.DNSRule = class extends CommonClass {
|
||||||
|
constructor(action = 'direct', qtype = '', domain = '') {
|
||||||
|
super();
|
||||||
|
this.action = action;
|
||||||
|
this.qtype = qtype;
|
||||||
|
this.domain = domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromJson(json = {}) {
|
||||||
|
return new Outbound.DNSRule(
|
||||||
|
json.action,
|
||||||
|
normalizeDNSRuleField(json.qtype),
|
||||||
|
normalizeDNSRuleField(json.domain),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
toJson() {
|
||||||
|
const rule = {
|
||||||
|
action: normalizeDNSRuleAction(this.action),
|
||||||
|
};
|
||||||
|
|
||||||
|
const qtype = normalizeDNSRuleField(this.qtype);
|
||||||
|
if (!ObjectUtil.isEmpty(qtype)) {
|
||||||
|
if (/^\d+$/.test(qtype)) {
|
||||||
|
rule.qtype = Number(qtype);
|
||||||
|
} else {
|
||||||
|
rule.qtype = qtype;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const domains = normalizeDNSRuleField(this.domain)
|
||||||
|
.split(',')
|
||||||
|
.map(d => d.trim())
|
||||||
|
.filter(d => d.length > 0);
|
||||||
|
if (domains.length > 0) {
|
||||||
|
rule.domain = domains;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rule;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
Outbound.DNSSettings = class extends CommonClass {
|
Outbound.DNSSettings = class extends CommonClass {
|
||||||
constructor(
|
constructor(
|
||||||
network = 'udp',
|
network = 'udp',
|
||||||
address = '',
|
address = '',
|
||||||
port = 53,
|
port = 53,
|
||||||
nonIPQuery = 'reject',
|
rules = []
|
||||||
blockTypes = []
|
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
this.network = network;
|
this.network = network;
|
||||||
this.address = address;
|
this.address = address;
|
||||||
this.port = port;
|
this.port = port;
|
||||||
this.nonIPQuery = nonIPQuery;
|
this.rules = Array.isArray(rules) ? rules.map(rule => rule instanceof Outbound.DNSRule ? rule : Outbound.DNSRule.fromJson(rule)) : [];
|
||||||
this.blockTypes = blockTypes;
|
}
|
||||||
|
|
||||||
|
addRule(action = 'direct') {
|
||||||
|
this.rules.push(new Outbound.DNSRule(action));
|
||||||
|
}
|
||||||
|
|
||||||
|
delRule(index) {
|
||||||
|
this.rules.splice(index, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
static fromJson(json = {}) {
|
static fromJson(json = {}) {
|
||||||
@@ -1268,10 +1609,23 @@ Outbound.DNSSettings = class extends CommonClass {
|
|||||||
json.network,
|
json.network,
|
||||||
json.address,
|
json.address,
|
||||||
json.port,
|
json.port,
|
||||||
json.nonIPQuery,
|
getDNSRulesFromJson(json),
|
||||||
json.blockTypes,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toJson() {
|
||||||
|
const json = {
|
||||||
|
network: this.network,
|
||||||
|
address: this.address,
|
||||||
|
port: this.port,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.rules.length > 0) {
|
||||||
|
json.rules = Outbound.DNSRule.toJsonArray(this.rules);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
Outbound.VmessSettings = class extends CommonClass {
|
Outbound.VmessSettings = class extends CommonClass {
|
||||||
constructor(address, port, id, security) {
|
constructor(address, port, id, security) {
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ class AllSetting {
|
|||||||
this.subPort = 2096;
|
this.subPort = 2096;
|
||||||
this.subPath = "/sub/";
|
this.subPath = "/sub/";
|
||||||
this.subJsonPath = "/json/";
|
this.subJsonPath = "/json/";
|
||||||
|
this.subClashEnable = true;
|
||||||
|
this.subClashPath = "/clash/";
|
||||||
this.subDomain = "";
|
this.subDomain = "";
|
||||||
this.externalTrafficInformEnable = false;
|
this.externalTrafficInformEnable = false;
|
||||||
this.externalTrafficInformURI = "";
|
this.externalTrafficInformURI = "";
|
||||||
@@ -48,6 +50,7 @@ class AllSetting {
|
|||||||
this.subShowInfo = true;
|
this.subShowInfo = true;
|
||||||
this.subURI = "";
|
this.subURI = "";
|
||||||
this.subJsonURI = "";
|
this.subJsonURI = "";
|
||||||
|
this.subClashURI = "";
|
||||||
this.subJsonFragment = "";
|
this.subJsonFragment = "";
|
||||||
this.subJsonNoises = "";
|
this.subJsonNoises = "";
|
||||||
this.subJsonMux = "";
|
this.subJsonMux = "";
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
sId: el.getAttribute('data-sid') || '',
|
sId: el.getAttribute('data-sid') || '',
|
||||||
subUrl: el.getAttribute('data-sub-url') || '',
|
subUrl: el.getAttribute('data-sub-url') || '',
|
||||||
subJsonUrl: el.getAttribute('data-subjson-url') || '',
|
subJsonUrl: el.getAttribute('data-subjson-url') || '',
|
||||||
|
subClashUrl: el.getAttribute('data-subclash-url') || '',
|
||||||
download: el.getAttribute('data-download') || '',
|
download: el.getAttribute('data-download') || '',
|
||||||
upload: el.getAttribute('data-upload') || '',
|
upload: el.getAttribute('data-upload') || '',
|
||||||
used: el.getAttribute('data-used') || '',
|
used: el.getAttribute('data-used') || '',
|
||||||
@@ -98,13 +99,19 @@
|
|||||||
this.lang = LanguageManager.getLanguage();
|
this.lang = LanguageManager.getLanguage();
|
||||||
const tpl = document.getElementById('subscription-data');
|
const tpl = document.getElementById('subscription-data');
|
||||||
const sj = tpl ? tpl.getAttribute('data-subjson-url') : '';
|
const sj = tpl ? tpl.getAttribute('data-subjson-url') : '';
|
||||||
|
const sc = tpl ? tpl.getAttribute('data-subclash-url') : '';
|
||||||
if (sj) this.app.subJsonUrl = sj;
|
if (sj) this.app.subJsonUrl = sj;
|
||||||
|
if (sc) this.app.subClashUrl = sc;
|
||||||
drawQR(this.app.subUrl);
|
drawQR(this.app.subUrl);
|
||||||
try {
|
try {
|
||||||
const elJson = document.getElementById('qrcode-subjson');
|
const elJson = document.getElementById('qrcode-subjson');
|
||||||
if (elJson && this.app.subJsonUrl) {
|
if (elJson && this.app.subJsonUrl) {
|
||||||
new QRious({ element: elJson, value: this.app.subJsonUrl, size: 220 });
|
new QRious({ element: elJson, value: this.app.subJsonUrl, size: 220 });
|
||||||
}
|
}
|
||||||
|
const elClash = document.getElementById('qrcode-subclash');
|
||||||
|
if (elClash && this.app.subClashUrl) {
|
||||||
|
new QRious({ element: elClash, value: this.app.subClashUrl, size: 220 });
|
||||||
|
}
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
this._onResize = () => { this.viewportWidth = window.innerWidth; };
|
this._onResize = () => { this.viewportWidth = window.innerWidth; };
|
||||||
window.addEventListener('resize', this._onResize);
|
window.addEventListener('resize', this._onResize);
|
||||||
@@ -144,7 +151,7 @@
|
|||||||
return this.app.subUrl;
|
return this.app.subUrl;
|
||||||
},
|
},
|
||||||
happUrl() {
|
happUrl() {
|
||||||
return `happ://add/${encodeURIComponent(this.app.subUrl)}`;
|
return `happ://add/${this.app.subUrl}`;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -152,6 +152,12 @@ class RandomUtil {
|
|||||||
return Base64.alternativeEncode(String.fromCharCode(...array));
|
return Base64.alternativeEncode(String.fromCharCode(...array));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static randomBase64(length = 16) {
|
||||||
|
const array = new Uint8Array(length);
|
||||||
|
window.crypto.getRandomValues(array);
|
||||||
|
return Base64.alternativeEncode(String.fromCharCode(...array));
|
||||||
|
}
|
||||||
|
|
||||||
static randomBase32String(length = 16) {
|
static randomBase32String(length = 16) {
|
||||||
const array = new Uint8Array(length);
|
const array = new Uint8Array(length);
|
||||||
|
|
||||||
@@ -651,10 +657,13 @@ class CookieManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static setCookie(cname, cvalue, exdays) {
|
static setCookie(cname, cvalue, exdays) {
|
||||||
|
let expires = '';
|
||||||
|
if (exdays) {
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
d.setTime(d.getTime() + exdays * 24 * 60 * 60 * 1000);
|
d.setTime(d.getTime() + exdays * 24 * 60 * 60 * 1000);
|
||||||
let expires = 'expires=' + d.toUTCString();
|
expires = 'expires=' + d.toUTCString() + ';';
|
||||||
document.cookie = cname + '=' + encodeURIComponent(cvalue) + ';' + expires + ';path=/';
|
}
|
||||||
|
document.cookie = cname + '=' + encodeURIComponent(cvalue) + ';' + expires + 'path=/';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -813,13 +822,13 @@ class LanguageManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (LanguageManager.isSupportLanguage(lang)) {
|
if (LanguageManager.isSupportLanguage(lang)) {
|
||||||
CookieManager.setCookie("lang", lang, 150);
|
CookieManager.setCookie("lang", lang);
|
||||||
} else {
|
} else {
|
||||||
CookieManager.setCookie("lang", "en-US", 150);
|
CookieManager.setCookie("lang", "en-US");
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
CookieManager.setCookie("lang", "en-US", 150);
|
CookieManager.setCookie("lang", "en-US");
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -832,7 +841,7 @@ class LanguageManager {
|
|||||||
language = "en-US";
|
language = "en-US";
|
||||||
}
|
}
|
||||||
|
|
||||||
CookieManager.setCookie("lang", language, 150);
|
CookieManager.setCookie("lang", language);
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ type APIController struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewAPIController creates a new APIController instance and initializes its routes.
|
// NewAPIController creates a new APIController instance and initializes its routes.
|
||||||
func NewAPIController(g *gin.RouterGroup) *APIController {
|
func NewAPIController(g *gin.RouterGroup, customGeo *service.CustomGeoService) *APIController {
|
||||||
a := &APIController{}
|
a := &APIController{}
|
||||||
a.initRouter(g)
|
a.initRouter(g, customGeo)
|
||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ func (a *APIController) checkAPIAuth(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// initRouter sets up the API routes for inbounds, server, and other endpoints.
|
// initRouter sets up the API routes for inbounds, server, and other endpoints.
|
||||||
func (a *APIController) initRouter(g *gin.RouterGroup) {
|
func (a *APIController) initRouter(g *gin.RouterGroup, customGeo *service.CustomGeoService) {
|
||||||
// Main API group
|
// Main API group
|
||||||
api := g.Group("/panel/api")
|
api := g.Group("/panel/api")
|
||||||
api.Use(a.checkAPIAuth)
|
api.Use(a.checkAPIAuth)
|
||||||
@@ -48,6 +48,8 @@ func (a *APIController) initRouter(g *gin.RouterGroup) {
|
|||||||
server := api.Group("/server")
|
server := api.Group("/server")
|
||||||
a.serverController = NewServerController(server)
|
a.serverController = NewServerController(server)
|
||||||
|
|
||||||
|
NewCustomGeoController(api.Group("/custom-geo"), customGeo)
|
||||||
|
|
||||||
// Extra routes
|
// Extra routes
|
||||||
api.GET("/backuptotgbot", a.BackuptoTgbot)
|
api.GET("/backuptotgbot", a.BackuptoTgbot)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v2/database/model"
|
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v2/web/service"
|
"github.com/mhsanaei/3x-ui/v2/web/service"
|
||||||
@@ -40,6 +41,7 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) {
|
|||||||
g.POST("/clientIps/:email", a.getClientIps)
|
g.POST("/clientIps/:email", a.getClientIps)
|
||||||
g.POST("/clearClientIps/:email", a.clearClientIps)
|
g.POST("/clearClientIps/:email", a.clearClientIps)
|
||||||
g.POST("/addClient", a.addInboundClient)
|
g.POST("/addClient", a.addInboundClient)
|
||||||
|
g.POST("/:id/copyClients", a.copyInboundClients)
|
||||||
g.POST("/:id/delClient/:clientId", a.delInboundClient)
|
g.POST("/:id/delClient/:clientId", a.delInboundClient)
|
||||||
g.POST("/updateClient/:clientId", a.updateInboundClient)
|
g.POST("/updateClient/:clientId", a.updateInboundClient)
|
||||||
g.POST("/:id/resetClientTraffic/:email", a.resetClientTraffic)
|
g.POST("/:id/resetClientTraffic/:email", a.resetClientTraffic)
|
||||||
@@ -53,6 +55,12 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) {
|
|||||||
g.POST("/:id/delClientByEmail/:email", a.delInboundClientByEmail)
|
g.POST("/:id/delClientByEmail/:email", a.delInboundClientByEmail)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CopyInboundClientsRequest struct {
|
||||||
|
SourceInboundID int `form:"sourceInboundId" json:"sourceInboundId"`
|
||||||
|
ClientEmails []string `form:"clientEmails" json:"clientEmails"`
|
||||||
|
Flow string `form:"flow" json:"flow"`
|
||||||
|
}
|
||||||
|
|
||||||
// getInbounds retrieves the list of inbounds for the logged-in user.
|
// getInbounds retrieves the list of inbounds for the logged-in user.
|
||||||
func (a *InboundController) getInbounds(c *gin.Context) {
|
func (a *InboundController) getInbounds(c *gin.Context) {
|
||||||
user := session.GetLoginUser(c)
|
user := session.GetLoginUser(c)
|
||||||
@@ -193,6 +201,37 @@ func (a *InboundController) getClientIps(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prefer returning a normalized string list for consistent UI rendering
|
||||||
|
type ipWithTimestamp struct {
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var ipsWithTime []ipWithTimestamp
|
||||||
|
if err := json.Unmarshal([]byte(ips), &ipsWithTime); err == nil && len(ipsWithTime) > 0 {
|
||||||
|
formatted := make([]string, 0, len(ipsWithTime))
|
||||||
|
for _, item := range ipsWithTime {
|
||||||
|
if item.IP == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if item.Timestamp > 0 {
|
||||||
|
ts := time.Unix(item.Timestamp, 0).Local().Format("2006-01-02 15:04:05")
|
||||||
|
formatted = append(formatted, fmt.Sprintf("%s (%s)", item.IP, ts))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
formatted = append(formatted, item.IP)
|
||||||
|
}
|
||||||
|
jsonObj(c, formatted, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var oldIps []string
|
||||||
|
if err := json.Unmarshal([]byte(ips), &oldIps); err == nil && len(oldIps) > 0 {
|
||||||
|
jsonObj(c, oldIps, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If parsing fails, return as string
|
||||||
jsonObj(c, ips, nil)
|
jsonObj(c, ips, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,6 +267,36 @@ func (a *InboundController) addInboundClient(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// copyInboundClients copies clients from source inbound to target inbound.
|
||||||
|
func (a *InboundController) copyInboundClients(c *gin.Context) {
|
||||||
|
targetID, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req := &CopyInboundClientsRequest{}
|
||||||
|
err = c.ShouldBind(req)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.SourceInboundID <= 0 {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), fmt.Errorf("invalid source inbound id"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, needRestart, err := a.inboundService.CopyInboundClients(targetID, req.SourceInboundID, req.ClientEmails, req.Flow)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonObj(c, result, nil)
|
||||||
|
if needRestart {
|
||||||
|
a.xrayService.SetToNeedRestart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// delInboundClient deletes a client from an inbound by inbound ID and client ID.
|
// delInboundClient deletes a client from an inbound by inbound ID and client ID.
|
||||||
func (a *InboundController) delInboundClient(c *gin.Context) {
|
func (a *InboundController) delInboundClient(c *gin.Context) {
|
||||||
id, err := strconv.Atoi(c.Param("id"))
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
|||||||
+11
-8
@@ -1,6 +1,7 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"text/template"
|
"text/template"
|
||||||
"time"
|
"time"
|
||||||
@@ -71,14 +72,22 @@ func (a *IndexController) login(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user := a.userService.CheckUser(form.Username, form.Password, form.TwoFactorCode)
|
user, checkErr := a.userService.CheckUser(form.Username, form.Password, form.TwoFactorCode)
|
||||||
timeStr := time.Now().Format("2006-01-02 15:04:05")
|
timeStr := time.Now().Format("2006-01-02 15:04:05")
|
||||||
safeUser := template.HTMLEscapeString(form.Username)
|
safeUser := template.HTMLEscapeString(form.Username)
|
||||||
safePass := template.HTMLEscapeString(form.Password)
|
safePass := template.HTMLEscapeString(form.Password)
|
||||||
|
|
||||||
if user == nil {
|
if user == nil {
|
||||||
logger.Warningf("wrong username: \"%s\", password: \"%s\", IP: \"%s\"", safeUser, safePass, getRemoteIp(c))
|
logger.Warningf("wrong username: \"%s\", password: \"%s\", IP: \"%s\"", safeUser, safePass, getRemoteIp(c))
|
||||||
a.tgbot.UserLoginNotify(safeUser, safePass, getRemoteIp(c), timeStr, 0)
|
|
||||||
|
notifyPass := safePass
|
||||||
|
|
||||||
|
if checkErr != nil && checkErr.Error() == "invalid 2fa code" {
|
||||||
|
translatedError := a.tgbot.I18nBot("tgbot.messages.2faFailed")
|
||||||
|
notifyPass = fmt.Sprintf("*** (%s)", translatedError)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.tgbot.UserLoginNotify(safeUser, notifyPass, getRemoteIp(c), timeStr, 0)
|
||||||
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.wrongUsernameOrPassword"))
|
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.wrongUsernameOrPassword"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -86,12 +95,6 @@ func (a *IndexController) login(c *gin.Context) {
|
|||||||
logger.Infof("%s logged in successfully, Ip Address: %s\n", safeUser, getRemoteIp(c))
|
logger.Infof("%s logged in successfully, Ip Address: %s\n", safeUser, getRemoteIp(c))
|
||||||
a.tgbot.UserLoginNotify(safeUser, ``, getRemoteIp(c), timeStr, 1)
|
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)
|
session.SetLoginUser(c, user)
|
||||||
if err := sessions.Default(c).Save(); err != nil {
|
if err := sessions.Default(c).Save(); err != nil {
|
||||||
logger.Warning("Unable to save session: ", err)
|
logger.Warning("Unable to save session: ", err)
|
||||||
|
|||||||
+10
-1
@@ -50,8 +50,17 @@ func jsonMsgObj(c *gin.Context, msg string, obj any, err error) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
m.Success = false
|
m.Success = false
|
||||||
m.Msg = msg + " (" + err.Error() + ")"
|
errStr := err.Error()
|
||||||
|
if errStr != "" {
|
||||||
|
m.Msg = msg + " (" + errStr + ")"
|
||||||
logger.Warning(msg+" "+I18nWeb(c, "fail")+": ", err)
|
logger.Warning(msg+" "+I18nWeb(c, "fail")+": ", err)
|
||||||
|
} else if msg != "" {
|
||||||
|
m.Msg = msg
|
||||||
|
logger.Warning(msg + " " + I18nWeb(c, "fail"))
|
||||||
|
} else {
|
||||||
|
m.Msg = I18nWeb(c, "somethingWentWrong")
|
||||||
|
logger.Warning(I18nWeb(c, "somethingWentWrong") + " " + I18nWeb(c, "fail"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, m)
|
c.JSON(http.StatusOK, m)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,8 +30,10 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var upgrader = ws.Upgrader{
|
var upgrader = ws.Upgrader{
|
||||||
ReadBufferSize: 4096, // Increased from 1024 for better performance
|
ReadBufferSize: 32768,
|
||||||
WriteBufferSize: 4096, // Increased from 1024 for better performance
|
WriteBufferSize: 32768,
|
||||||
|
EnableCompression: true, // Negotiate permessage-deflate compression if the client supports it
|
||||||
|
|
||||||
CheckOrigin: func(r *http.Request) bool {
|
CheckOrigin: func(r *http.Request) bool {
|
||||||
// Check origin for security
|
// Check origin for security
|
||||||
origin := r.Header.Get("Origin")
|
origin := r.Header.Get("Origin")
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/util/common"
|
||||||
"github.com/mhsanaei/3x-ui/v2/web/service"
|
"github.com/mhsanaei/3x-ui/v2/web/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -14,6 +17,7 @@ type XraySettingController struct {
|
|||||||
OutboundService service.OutboundService
|
OutboundService service.OutboundService
|
||||||
XrayService service.XrayService
|
XrayService service.XrayService
|
||||||
WarpService service.WarpService
|
WarpService service.WarpService
|
||||||
|
NordService service.NordService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewXraySettingController creates a new XraySettingController and initializes its routes.
|
// NewXraySettingController creates a new XraySettingController and initializes its routes.
|
||||||
@@ -32,31 +36,71 @@ func (a *XraySettingController) initRouter(g *gin.RouterGroup) {
|
|||||||
|
|
||||||
g.POST("/", a.getXraySetting)
|
g.POST("/", a.getXraySetting)
|
||||||
g.POST("/warp/:action", a.warp)
|
g.POST("/warp/:action", a.warp)
|
||||||
|
g.POST("/nord/:action", a.nord)
|
||||||
g.POST("/update", a.updateSetting)
|
g.POST("/update", a.updateSetting)
|
||||||
g.POST("/resetOutboundsTraffic", a.resetOutboundsTraffic)
|
g.POST("/resetOutboundsTraffic", a.resetOutboundsTraffic)
|
||||||
|
g.POST("/testOutbound", a.testOutbound)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getXraySetting retrieves the Xray configuration template and inbound tags.
|
// getXraySetting retrieves the Xray configuration template, inbound tags, and outbound test URL.
|
||||||
func (a *XraySettingController) getXraySetting(c *gin.Context) {
|
func (a *XraySettingController) getXraySetting(c *gin.Context) {
|
||||||
xraySetting, err := a.SettingService.GetXrayConfigTemplate()
|
xraySetting, err := a.SettingService.GetXrayConfigTemplate()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
|
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Older versions of this handler embedded the raw DB value as
|
||||||
|
// `xraySetting` in the response without checking if the value
|
||||||
|
// already had that wrapper shape. When the frontend saved it
|
||||||
|
// back through the textarea verbatim, the wrapper got persisted
|
||||||
|
// and every subsequent save nested another layer, which is what
|
||||||
|
// eventually produced the blank Xray Settings page in #4059.
|
||||||
|
// Strip any such wrapper here, and heal the DB if we found one so
|
||||||
|
// the next read is O(1) instead of climbing the same pile again.
|
||||||
|
if unwrapped := service.UnwrapXrayTemplateConfig(xraySetting); unwrapped != xraySetting {
|
||||||
|
if saveErr := a.XraySettingService.SaveXraySetting(unwrapped); saveErr == nil {
|
||||||
|
xraySetting = unwrapped
|
||||||
|
} else {
|
||||||
|
// Don't fail the read — just serve the unwrapped value
|
||||||
|
// and leave the DB healing for a later save.
|
||||||
|
xraySetting = unwrapped
|
||||||
|
}
|
||||||
|
}
|
||||||
inboundTags, err := a.InboundService.GetInboundTags()
|
inboundTags, err := a.InboundService.GetInboundTags()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
|
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
xrayResponse := "{ \"xraySetting\": " + xraySetting + ", \"inboundTags\": " + inboundTags + " }"
|
outboundTestUrl, _ := a.SettingService.GetXrayOutboundTestUrl()
|
||||||
jsonObj(c, xrayResponse, nil)
|
if outboundTestUrl == "" {
|
||||||
|
outboundTestUrl = "https://www.google.com/generate_204"
|
||||||
|
}
|
||||||
|
xrayResponse := map[string]interface{}{
|
||||||
|
"xraySetting": json.RawMessage(xraySetting),
|
||||||
|
"inboundTags": json.RawMessage(inboundTags),
|
||||||
|
"outboundTestUrl": outboundTestUrl,
|
||||||
|
}
|
||||||
|
result, err := json.Marshal(xrayResponse)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonObj(c, string(result), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateSetting updates the Xray configuration settings.
|
// updateSetting updates the Xray configuration settings.
|
||||||
func (a *XraySettingController) updateSetting(c *gin.Context) {
|
func (a *XraySettingController) updateSetting(c *gin.Context) {
|
||||||
xraySetting := c.PostForm("xraySetting")
|
xraySetting := c.PostForm("xraySetting")
|
||||||
err := a.XraySettingService.SaveXraySetting(xraySetting)
|
if err := a.XraySettingService.SaveXraySetting(xraySetting); err != nil {
|
||||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
|
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
outboundTestUrl := c.PostForm("outboundTestUrl")
|
||||||
|
if outboundTestUrl == "" {
|
||||||
|
outboundTestUrl = "https://www.google.com/generate_204"
|
||||||
|
}
|
||||||
|
_ = a.SettingService.SetXrayOutboundTestUrl(outboundTestUrl)
|
||||||
|
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getDefaultXrayConfig retrieves the default Xray configuration.
|
// getDefaultXrayConfig retrieves the default Xray configuration.
|
||||||
@@ -98,6 +142,32 @@ func (a *XraySettingController) warp(c *gin.Context) {
|
|||||||
jsonObj(c, resp, err)
|
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.
|
// getOutboundsTraffic retrieves the traffic statistics for outbounds.
|
||||||
func (a *XraySettingController) getOutboundsTraffic(c *gin.Context) {
|
func (a *XraySettingController) getOutboundsTraffic(c *gin.Context) {
|
||||||
outboundsTraffic, err := a.OutboundService.GetOutboundsTraffic()
|
outboundsTraffic, err := a.OutboundService.GetOutboundsTraffic()
|
||||||
@@ -118,3 +188,26 @@ func (a *XraySettingController) resetOutboundsTraffic(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
jsonObj(c, "", nil)
|
jsonObj(c, "", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// testOutbound tests an outbound configuration and returns the delay/response time.
|
||||||
|
// Optional form "allOutbounds": JSON array of all outbounds; used to resolve sockopt.dialerProxy dependencies.
|
||||||
|
func (a *XraySettingController) testOutbound(c *gin.Context) {
|
||||||
|
outboundJSON := c.PostForm("outbound")
|
||||||
|
allOutboundsJSON := c.PostForm("allOutbounds")
|
||||||
|
|
||||||
|
if outboundJSON == "" {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), common.NewError("outbound parameter is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the test URL from server settings to prevent SSRF via user-controlled URLs
|
||||||
|
testURL, _ := a.SettingService.GetXrayOutboundTestUrl()
|
||||||
|
|
||||||
|
result, err := a.OutboundService.TestOutbound(outboundJSON, testURL, allOutboundsJSON)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonObj(c, result, nil)
|
||||||
|
}
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ type AllSetting struct {
|
|||||||
SubURI string `json:"subURI" form:"subURI"` // Subscription server URI
|
SubURI string `json:"subURI" form:"subURI"` // Subscription server URI
|
||||||
SubJsonPath string `json:"subJsonPath" form:"subJsonPath"` // Path for JSON subscription endpoint
|
SubJsonPath string `json:"subJsonPath" form:"subJsonPath"` // Path for JSON subscription endpoint
|
||||||
SubJsonURI string `json:"subJsonURI" form:"subJsonURI"` // JSON subscription server URI
|
SubJsonURI string `json:"subJsonURI" form:"subJsonURI"` // JSON subscription server URI
|
||||||
|
SubClashEnable bool `json:"subClashEnable" form:"subClashEnable"` // Enable Clash/Mihomo subscription endpoint
|
||||||
|
SubClashPath string `json:"subClashPath" form:"subClashPath"` // Path for Clash/Mihomo subscription endpoint
|
||||||
|
SubClashURI string `json:"subClashURI" form:"subClashURI"` // Clash/Mihomo subscription server URI
|
||||||
SubJsonFragment string `json:"subJsonFragment" form:"subJsonFragment"` // JSON subscription fragment configuration
|
SubJsonFragment string `json:"subJsonFragment" form:"subJsonFragment"` // JSON subscription fragment configuration
|
||||||
SubJsonNoises string `json:"subJsonNoises" form:"subJsonNoises"` // JSON subscription noise configuration
|
SubJsonNoises string `json:"subJsonNoises" form:"subJsonNoises"` // JSON subscription noise configuration
|
||||||
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration
|
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration
|
||||||
@@ -168,6 +171,13 @@ func (s *AllSetting) CheckValid() error {
|
|||||||
s.SubJsonPath += "/"
|
s.SubJsonPath += "/"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(s.SubClashPath, "/") {
|
||||||
|
s.SubClashPath = "/" + s.SubClashPath
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(s.SubClashPath, "/") {
|
||||||
|
s.SubClashPath += "/"
|
||||||
|
}
|
||||||
|
|
||||||
_, err := time.LoadLocation(s.TimeLocation)
|
_, err := time.LoadLocation(s.TimeLocation)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return common.NewError("time location not exist:", s.TimeLocation)
|
return common.NewError("time location not exist:", s.TimeLocation)
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
</template>
|
</template>
|
||||||
<template slot="enable" slot-scope="text, client, index">
|
<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>
|
||||||
<template slot="online" slot-scope="text, client, index">
|
<template slot="online" slot-scope="text, client, index">
|
||||||
<a-popover :overlay-class-name="themeSwitcher.currentTheme">
|
<a-popover :overlay-class-name="themeSwitcher.currentTheme">
|
||||||
@@ -165,7 +165,7 @@
|
|||||||
<span :style="{ color: '#FF4D4F' }"> {{ i18n "delete"}}</span>
|
<span :style="{ color: '#FF4D4F' }"> {{ i18n "delete"}}</span>
|
||||||
</a-menu-item>
|
</a-menu-item>
|
||||||
<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"}}
|
{{ i18n "enable"}}
|
||||||
</a-menu-item>
|
</a-menu-item>
|
||||||
</a-menu>
|
</a-menu>
|
||||||
|
|||||||
@@ -40,7 +40,8 @@
|
|||||||
{{define "component/aThemeSwitch"}}
|
{{define "component/aThemeSwitch"}}
|
||||||
<script>
|
<script>
|
||||||
function createThemeSwitcher() {
|
function createThemeSwitcher() {
|
||||||
const isDarkTheme = localStorage.getItem('dark-mode') === 'true';
|
const darkModePreference = localStorage.getItem('dark-mode');
|
||||||
|
const isDarkTheme = darkModePreference === null ? true : darkModePreference === 'true';
|
||||||
const isUltra = localStorage.getItem('isUltraDarkThemeEnabled') === 'true';
|
const isUltra = localStorage.getItem('isUltraDarkThemeEnabled') === 'true';
|
||||||
if (isUltra) {
|
if (isUltra) {
|
||||||
document.documentElement.setAttribute('data-theme', 'ultra-dark');
|
document.documentElement.setAttribute('data-theme', 'ultra-dark');
|
||||||
|
|||||||
+114
-30
@@ -1,5 +1,11 @@
|
|||||||
{{define "form/client"}}
|
{{define "form/client"}}
|
||||||
<a-form layout="horizontal" v-if="client" :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
<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-form-item label='{{ i18n "pages.inbounds.enable" }}'>
|
||||||
<a-switch v-model="client.enable"></a-switch>
|
<a-switch v-model="client.enable"></a-switch>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
@@ -10,38 +16,81 @@
|
|||||||
<span>{{ i18n "pages.inbounds.emailDesc" }}</span>
|
<span>{{ i18n "pages.inbounds.emailDesc" }}</span>
|
||||||
</template>
|
</template>
|
||||||
{{ i18n "pages.inbounds.email" }}
|
{{ i18n "pages.inbounds.email" }}
|
||||||
<a-icon type="sync" @click="client.email = RandomUtil.randomLowerAndNum(9)"></a-icon>
|
<a-icon
|
||||||
|
type="sync"
|
||||||
|
@click="client.email = RandomUtil.randomLowerAndNum(9)"
|
||||||
|
></a-icon>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
</template>
|
</template>
|
||||||
<a-input v-model.trim="client.email"></a-input>
|
<a-input v-model.trim="client.email"></a-input>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item v-if="inbound.protocol === Protocols.TROJAN || inbound.protocol === Protocols.SHADOWSOCKS">
|
<a-form-item
|
||||||
|
v-if="inbound.protocol === Protocols.TROJAN || inbound.protocol === Protocols.SHADOWSOCKS"
|
||||||
|
>
|
||||||
<template slot="label">
|
<template slot="label">
|
||||||
<a-tooltip>
|
<a-tooltip>
|
||||||
<template slot="title">
|
<template slot="title">
|
||||||
<span>{{ i18n "reset" }}</span>
|
<span>{{ i18n "reset" }}</span>
|
||||||
</template>
|
</template>
|
||||||
{{ i18n "password" }}
|
{{ i18n "password" }}
|
||||||
<a-icon v-if="inbound.protocol === Protocols.SHADOWSOCKS" @click="client.password = RandomUtil.randomShadowsocksPassword(inbound.settings.method)" type="sync"></a-icon>
|
<a-icon
|
||||||
<a-icon v-if="inbound.protocol === Protocols.TROJAN" @click="client.password = RandomUtil.randomSeq(10)"type="sync"> </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>
|
</a-tooltip>
|
||||||
</template>
|
</template>
|
||||||
<a-input v-model.trim="client.password"></a-input>
|
<a-input v-model.trim="client.password"></a-input>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item v-if="inbound.protocol === Protocols.VMESS || inbound.protocol === Protocols.VLESS">
|
<a-form-item v-if="inbound.protocol === Protocols.HYSTERIA">
|
||||||
<template slot="label">
|
<template slot="label">
|
||||||
<a-tooltip>
|
<a-tooltip>
|
||||||
<template slot="title">
|
<template slot="title">
|
||||||
<span>{{ i18n "reset" }}</span>
|
<span>{{ i18n "reset" }}</span>
|
||||||
</template>
|
</template>
|
||||||
ID <a-icon @click="client.id = RandomUtil.randomUUID()" type="sync"></a-icon>
|
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>
|
||||||
|
ID
|
||||||
|
<a-icon
|
||||||
|
@click="client.id = RandomUtil.randomUUID()"
|
||||||
|
type="sync"
|
||||||
|
></a-icon>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
</template>
|
</template>
|
||||||
<a-input v-model.trim="client.id"></a-input>
|
<a-input v-model.trim="client.id"></a-input>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item v-if="inbound.protocol === Protocols.VMESS" label='{{ i18n "security" }}'>
|
<a-form-item
|
||||||
<a-select v-model="client.security" :dropdown-class-name="themeSwitcher.currentTheme">
|
v-if="inbound.protocol === Protocols.VMESS"
|
||||||
<a-select-option v-for="key in USERS_SECURITY" :value="key">[[ key ]]</a-select-option>
|
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-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item v-if="client.email && app.subSettings?.enable">
|
<a-form-item v-if="client.email && app.subSettings?.enable">
|
||||||
@@ -51,7 +100,10 @@
|
|||||||
<span>{{ i18n "pages.inbounds.subscriptionDesc" }}</span>
|
<span>{{ i18n "pages.inbounds.subscriptionDesc" }}</span>
|
||||||
</template>
|
</template>
|
||||||
Subscription
|
Subscription
|
||||||
<a-icon @click="client.subId = RandomUtil.randomLowerAndNum(16)" type="sync"></a-icon>
|
<a-icon
|
||||||
|
@click="client.subId = RandomUtil.randomLowerAndNum(16)"
|
||||||
|
type="sync"
|
||||||
|
></a-icon>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
</template>
|
</template>
|
||||||
<a-input v-model.trim="client.subId"></a-input>
|
<a-input v-model.trim="client.subId"></a-input>
|
||||||
@@ -66,7 +118,11 @@
|
|||||||
<a-icon type="question-circle"></a-icon>
|
<a-icon type="question-circle"></a-icon>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
</template>
|
</template>
|
||||||
<a-input-number :style="{ width: '50%' }" v-model.number="client.tgId" min="0"></a-input-number>
|
<a-input-number
|
||||||
|
:style="{ width: '50%' }"
|
||||||
|
v-model.number="client.tgId"
|
||||||
|
min="0"
|
||||||
|
></a-input-number>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item v-if="client.email" label='{{ i18n "comment" }}'>
|
<a-form-item v-if="client.email" label='{{ i18n "comment" }}'>
|
||||||
<a-input v-model.trim="client.comment"></a-input>
|
<a-input v-model.trim="client.comment"></a-input>
|
||||||
@@ -83,7 +139,9 @@
|
|||||||
</template>
|
</template>
|
||||||
<a-input-number v-model.number="client.limitIp" min="0"></a-input-number>
|
<a-input-number v-model.number="client.limitIp" min="0"></a-input-number>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item v-if="app.ipLimitEnable && client.limitIp > 0 && client.email && isEdit">
|
<a-form-item
|
||||||
|
v-if="app.ipLimitEnable && client.limitIp > 0 && client.email && isEdit"
|
||||||
|
>
|
||||||
<template slot="label">
|
<template slot="label">
|
||||||
<a-tooltip>
|
<a-tooltip>
|
||||||
<template slot="title">
|
<template slot="title">
|
||||||
@@ -102,15 +160,25 @@
|
|||||||
</span>
|
</span>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
<a-form layout="block">
|
<a-form layout="block">
|
||||||
<a-textarea id="clientIPs" readonly @click="getDBClientIps(client.email)" placeholder="Click To Get IPs"
|
<a-textarea
|
||||||
:auto-size="{ minRows: 5, maxRows: 10 }">
|
id="clientIPs"
|
||||||
|
readonly
|
||||||
|
@click="getDBClientIps(client.email)"
|
||||||
|
placeholder="Click To Get IPs"
|
||||||
|
:auto-size="{ minRows: 5, maxRows: 10 }"
|
||||||
|
>
|
||||||
</a-textarea>
|
</a-textarea>
|
||||||
</a-form>
|
</a-form>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item v-if="inbound.canEnableTlsFlow()" label='Flow'>
|
<a-form-item v-if="inbound.canEnableTlsFlow()" label="Flow">
|
||||||
<a-select v-model="client.flow" :dropdown-class-name="themeSwitcher.currentTheme">
|
<a-select
|
||||||
<a-select-option value="" selected>{{ i18n "none" }}</a-select-option>
|
v-model="client.flow"
|
||||||
<a-select-option v-for="key in TLS_FLOW_CONTROL" :value="key">[[ key ]]</a-select-option>
|
: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-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item>
|
<a-form-item>
|
||||||
@@ -127,35 +195,51 @@
|
|||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item v-if="isEdit && clientStats" label='{{ i18n "usage" }}'>
|
<a-form-item v-if="isEdit && clientStats" label='{{ i18n "usage" }}'>
|
||||||
<a-tag :color="ColorUtils.clientUsageColor(clientStats, app.trafficDiff)">
|
<a-tag :color="ColorUtils.clientUsageColor(clientStats, app.trafficDiff)">
|
||||||
[[ SizeFormatter.sizeFormat(clientStats.up) ]] /
|
[[ SizeFormatter.sizeFormat(clientStats.up) ]] / [[
|
||||||
[[ SizeFormatter.sizeFormat(clientStats.down) ]]
|
SizeFormatter.sizeFormat(clientStats.down) ]] ([[
|
||||||
([[ SizeFormatter.sizeFormat(clientStats.up + clientStats.down) ]])
|
SizeFormatter.sizeFormat(clientStats.up + clientStats.down) ]])
|
||||||
</a-tag>
|
</a-tag>
|
||||||
<a-tooltip>
|
<a-tooltip>
|
||||||
<template slot="title">{{ i18n "pages.inbounds.resetTraffic" }}</template>
|
<template slot="title">{{ i18n "pages.inbounds.resetTraffic" }}</template>
|
||||||
<a-icon type="retweet"
|
<a-icon
|
||||||
|
type="retweet"
|
||||||
@click="resetClientTraffic(client.email,clientStats.inboundId,$event.target)"
|
@click="resetClientTraffic(client.email,clientStats.inboundId,$event.target)"
|
||||||
v-if="client.email.length > 0"></a-icon>
|
v-if="client.email.length > 0"
|
||||||
|
></a-icon>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label='{{ i18n "pages.client.delayedStart" }}'>
|
<a-form-item label='{{ i18n "pages.client.delayedStart" }}'>
|
||||||
<a-switch v-model="delayedStart" @click="client._expiryTime=0"></a-switch>
|
<a-switch v-model="delayedStart" @click="client._expiryTime=0"></a-switch>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item v-if="delayedStart" label='{{ i18n "pages.client.expireDays" }}'>
|
<a-form-item v-if="delayedStart" label='{{ i18n "pages.client.expireDays" }}'>
|
||||||
<a-input-number v-model.number="delayedExpireDays" :min="0"></a-input-number>
|
<a-input-number
|
||||||
|
v-model.number="delayedExpireDays"
|
||||||
|
:min="0"
|
||||||
|
></a-input-number>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item v-else>
|
<a-form-item v-else>
|
||||||
<template slot="label">
|
<template slot="label">
|
||||||
<a-tooltip>
|
<a-tooltip>
|
||||||
<template slot="title">{{ i18n "pages.inbounds.leaveBlankToNeverExpire" }}</template>
|
<template slot="title"
|
||||||
|
>{{ i18n "pages.inbounds.leaveBlankToNeverExpire" }}</template
|
||||||
|
>
|
||||||
{{ i18n "pages.inbounds.expireDate" }}
|
{{ i18n "pages.inbounds.expireDate" }}
|
||||||
<a-icon type="question-circle"></a-icon>
|
<a-icon type="question-circle"></a-icon>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
</template>
|
</template>
|
||||||
<a-date-picker v-if="datepicker == 'gregorian'" :show-time="{ format: 'HH:mm:ss' }" format="YYYY-MM-DD HH:mm:ss"
|
<a-date-picker
|
||||||
:dropdown-class-name="themeSwitcher.currentTheme" v-model="client._expiryTime"></a-date-picker>
|
v-if="datepicker == 'gregorian'"
|
||||||
<a-persian-datepicker v-else placeholder='{{ i18n "pages.settings.datepickerPlaceholder" }}'
|
:show-time="{ format: 'HH:mm:ss' }"
|
||||||
value="client._expiryTime" v-model="client._expiryTime"></a-persian-datepicker>
|
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-tag color="red" v-if="isEdit && isExpiry">Expired</a-tag>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item v-if="client.expiryTime != 0">
|
<a-form-item v-if="client.expiryTime != 0">
|
||||||
|
|||||||
+18
-20
@@ -1,7 +1,6 @@
|
|||||||
{{define "form/inbound"}}
|
{{define "form/inbound"}}
|
||||||
<!-- base -->
|
<!-- base -->
|
||||||
<a-form :colon="false" :label-col="{ md: {span:8} }"
|
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||||
:wrapper-col="{ md: {span:14} }">
|
|
||||||
<a-form-item label='{{ i18n "enable" }}'>
|
<a-form-item label='{{ i18n "enable" }}'>
|
||||||
<a-switch v-model="dbInbound.enable"></a-switch>
|
<a-switch v-model="dbInbound.enable"></a-switch>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
@@ -10,8 +9,7 @@
|
|||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
<a-form-item label='{{ i18n "protocol" }}'>
|
<a-form-item label='{{ i18n "protocol" }}'>
|
||||||
<a-select v-model="inbound.protocol" :disabled="isEdit"
|
<a-select v-model="inbound.protocol" :disabled="isEdit" :dropdown-class-name="themeSwitcher.currentTheme">
|
||||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
|
||||||
<a-select-option v-for="p in Protocols" :key="p" :value="p">[[ p
|
<a-select-option v-for="p in Protocols" :key="p" :value="p">[[ p
|
||||||
]]</a-select-option>
|
]]</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
@@ -31,8 +29,7 @@
|
|||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
<a-form-item label='{{ i18n "pages.inbounds.port" }}'>
|
<a-form-item label='{{ i18n "pages.inbounds.port" }}'>
|
||||||
<a-input-number v-model.number="inbound.port" :min="1"
|
<a-input-number v-model.number="inbound.port" :min="1" :max="65535"></a-input-number>
|
||||||
:max="65535"></a-input-number>
|
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
<a-form-item>
|
<a-form-item>
|
||||||
@@ -45,8 +42,7 @@
|
|||||||
<a-icon type="question-circle"></a-icon>
|
<a-icon type="question-circle"></a-icon>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
</template>
|
</template>
|
||||||
<a-input-number v-model.number="dbInbound.totalGB"
|
<a-input-number v-model.number="dbInbound.totalGB" :min="0"></a-input-number>
|
||||||
:min="0"></a-input-number>
|
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
<a-form-item>
|
<a-form-item>
|
||||||
@@ -55,10 +51,8 @@
|
|||||||
<template slot="title">
|
<template slot="title">
|
||||||
<span>{{ i18n "pages.inbounds.periodicTrafficResetDesc"
|
<span>{{ i18n "pages.inbounds.periodicTrafficResetDesc"
|
||||||
}}</span>
|
}}</span>
|
||||||
<br
|
<br v-if="dbInbound.lastTrafficResetTime && dbInbound.lastTrafficResetTime > 0">
|
||||||
v-if="dbInbound.lastTrafficResetTime && dbInbound.lastTrafficResetTime > 0">
|
<span v-if="dbInbound.lastTrafficResetTime && dbInbound.lastTrafficResetTime > 0">
|
||||||
<span
|
|
||||||
v-if="dbInbound.lastTrafficResetTime && dbInbound.lastTrafficResetTime > 0">
|
|
||||||
<strong>{{ i18n "pages.inbounds.lastReset" }}:</strong>
|
<strong>{{ i18n "pages.inbounds.lastReset" }}:</strong>
|
||||||
<span>[[
|
<span>[[
|
||||||
IntlUtil.formatDate(dbInbound.lastTrafficResetTime)
|
IntlUtil.formatDate(dbInbound.lastTrafficResetTime)
|
||||||
@@ -69,10 +63,12 @@
|
|||||||
<a-icon type="question-circle"></a-icon>
|
<a-icon type="question-circle"></a-icon>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
</template>
|
</template>
|
||||||
<a-select v-model="dbInbound.trafficReset"
|
<a-select v-model="dbInbound.trafficReset" :dropdown-class-name="themeSwitcher.currentTheme">
|
||||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
|
||||||
<a-select-option value="never">{{ i18n
|
<a-select-option value="never">{{ i18n
|
||||||
"pages.inbounds.periodicTrafficReset.never" }}</a-select-option>
|
"pages.inbounds.periodicTrafficReset.never" }}</a-select-option>
|
||||||
|
<a-select-option value="hourly">{{ i18n
|
||||||
|
"pages.inbounds.periodicTrafficReset.hourly"
|
||||||
|
}}</a-select-option>
|
||||||
<a-select-option value="daily">{{ i18n
|
<a-select-option value="daily">{{ i18n
|
||||||
"pages.inbounds.periodicTrafficReset.daily" }}</a-select-option>
|
"pages.inbounds.periodicTrafficReset.daily" }}</a-select-option>
|
||||||
<a-select-option value="weekly">{{ i18n
|
<a-select-option value="weekly">{{ i18n
|
||||||
@@ -95,13 +91,10 @@
|
|||||||
<a-icon type="question-circle"></a-icon>
|
<a-icon type="question-circle"></a-icon>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
</template>
|
</template>
|
||||||
<a-date-picker :style="{ width: '100%' }"
|
<a-date-picker :style="{ width: '100%' }" v-if="datepicker == 'gregorian'" :show-time="{ format: 'HH:mm:ss' }"
|
||||||
v-if="datepicker == 'gregorian'" :show-time="{ format: 'HH:mm:ss' }"
|
format="YYYY-MM-DD HH:mm:ss" :dropdown-class-name="themeSwitcher.currentTheme"
|
||||||
format="YYYY-MM-DD HH:mm:ss"
|
|
||||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
|
||||||
v-model="dbInbound._expiryTime"></a-date-picker>
|
v-model="dbInbound._expiryTime"></a-date-picker>
|
||||||
<a-persian-datepicker v-else
|
<a-persian-datepicker v-else placeholder='{{ i18n "pages.settings.datepickerPlaceholder" }}'
|
||||||
placeholder='{{ i18n "pages.settings.datepickerPlaceholder" }}'
|
|
||||||
value="dbInbound._expiryTime" v-model="dbInbound._expiryTime">
|
value="dbInbound._expiryTime" v-model="dbInbound._expiryTime">
|
||||||
</a-persian-datepicker>
|
</a-persian-datepicker>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
@@ -152,6 +145,11 @@
|
|||||||
{{template "form/tun"}}
|
{{template "form/tun"}}
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- hysteria -->
|
||||||
|
<template v-if="inbound.protocol === Protocols.HYSTERIA">
|
||||||
|
{{template "form/hysteria"}}
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- stream settings -->
|
<!-- stream settings -->
|
||||||
<template v-if="inbound.canEnableStream()">
|
<template v-if="inbound.canEnableStream()">
|
||||||
{{template "form/streamSettings"}}
|
{{template "form/streamSettings"}}
|
||||||
|
|||||||
+1078
-352
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,6 +1,10 @@
|
|||||||
{{define "form/shadowsocks"}}
|
{{define "form/shadowsocks"}}
|
||||||
<template v-if="inbound.isSSMultiUser">
|
<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
|
||||||
|
activeKey="0"
|
||||||
|
v-for="(client, index) in inbound.settings.shadowsockses.slice(0,1)"
|
||||||
|
v-if="!isEdit"
|
||||||
|
>
|
||||||
<a-collapse-panel header='{{ i18n "pages.inbounds.client" }}'>
|
<a-collapse-panel header='{{ i18n "pages.inbounds.client" }}'>
|
||||||
{{template "form/client"}}
|
{{template "form/client"}}
|
||||||
</a-collapse-panel>
|
</a-collapse-panel>
|
||||||
@@ -12,7 +16,10 @@
|
|||||||
<th>{{ i18n "pages.inbounds.email" }}</th>
|
<th>{{ i18n "pages.inbounds.email" }}</th>
|
||||||
<th>Password</th>
|
<th>Password</th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-for="(client, index) in inbound.settings.shadowsockses" :class="index % 2 == 1 ? 'client-table-odd-row' : ''">
|
<tr
|
||||||
|
v-for="(client, index) in inbound.settings.shadowsockses"
|
||||||
|
:class="index % 2 == 1 ? ' client-table-odd-row' : ''"
|
||||||
|
>
|
||||||
<td>[[ client.email ]]</td>
|
<td>[[ client.email ]]</td>
|
||||||
<td>[[ client.password ]]</td>
|
<td>[[ client.password ]]</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -20,10 +27,20 @@
|
|||||||
</a-collapse-panel>
|
</a-collapse-panel>
|
||||||
</a-collapse>
|
</a-collapse>
|
||||||
</template>
|
</template>
|
||||||
<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 "encryption" }}'>
|
<a-form-item label='{{ i18n "encryption" }}'>
|
||||||
<a-select v-model="inbound.settings.method" @change="SSMethodChange" :dropdown-class-name="themeSwitcher.currentTheme">
|
<a-select
|
||||||
<a-select-option v-for="(method,method_name) in SSMethods" :value="method">[[ method_name ]]</a-select-option>
|
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-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item v-if="inbound.isSS2022">
|
<a-form-item v-if="inbound.isSS2022">
|
||||||
@@ -31,19 +48,28 @@
|
|||||||
<a-tooltip>
|
<a-tooltip>
|
||||||
<template slot="title">
|
<template slot="title">
|
||||||
<span>{{ i18n "reset" }}</span>
|
<span>{{ i18n "reset" }}</span>
|
||||||
</template> Password <a-icon @click="inbound.settings.password = RandomUtil.randomShadowsocksPassword(inbound.settings.method)" type="sync"></a-icon>
|
</template>
|
||||||
|
Password
|
||||||
|
<a-icon
|
||||||
|
@click="inbound.settings.password = RandomUtil.randomShadowsocksPassword(inbound.settings.method)"
|
||||||
|
type="sync"
|
||||||
|
></a-icon>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
</template>
|
</template>
|
||||||
<a-input v-model.trim="inbound.settings.password"></a-input>
|
<a-input v-model.trim="inbound.settings.password"></a-input>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label='{{ i18n "pages.inbounds.network" }}'>
|
<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
|
||||||
|
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,udp">TCP,UDP</a-select-option>
|
||||||
<a-select-option value="tcp">TCP</a-select-option>
|
<a-select-option value="tcp">TCP</a-select-option>
|
||||||
<a-select-option value="udp">UDP</a-select-option>
|
<a-select-option value="udp">UDP</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label='ivCheck'>
|
<a-form-item label="ivCheck">
|
||||||
<a-switch v-model="inbound.settings.ivCheck"></a-switch>
|
<a-switch v-model="inbound.settings.ivCheck"></a-switch>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-form>
|
</a-form>
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
{{define "form/mixed"}}
|
{{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-form-item label='{{ i18n "pages.inbounds.enable" }} UDP'>
|
||||||
<a-switch v-model="inbound.settings.udp"></a-switch>
|
<a-switch v-model="inbound.settings.udp"></a-switch>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
@@ -7,7 +11,10 @@
|
|||||||
<a-input v-model.trim="inbound.settings.ip"></a-input>
|
<a-input v-model.trim="inbound.settings.ip"></a-input>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label='{{ i18n "password" }}'>
|
<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>
|
</a-form-item>
|
||||||
<template v-if="inbound.settings.auth === 'password'">
|
<template v-if="inbound.settings.auth === 'password'">
|
||||||
<table :style="{ width: '100%', textAlign: 'center', margin: '1rem 0' }">
|
<table :style="{ width: '100%', textAlign: 'center', margin: '1rem 0' }">
|
||||||
@@ -15,17 +22,39 @@
|
|||||||
<td width="45%">{{ i18n "username" }}</td>
|
<td width="45%">{{ i18n "username" }}</td>
|
||||||
<td width="45%">{{ i18n "password" }}</td>
|
<td width="45%">{{ i18n "password" }}</td>
|
||||||
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
<a-input-group compact v-for="(account, index) in inbound.settings.accounts" :style="{ marginBottom: '10px' }">
|
<a-input-group
|
||||||
<a-input :style="{ width: '50%' }" v-model.trim="account.user" placeholder='{{ i18n "username" }}'>
|
compact
|
||||||
<template slot="addonBefore" :style="{ margin: '0' }">[[ index+1 ]]</template>
|
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>
|
||||||
<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">
|
<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>
|
</template>
|
||||||
</a-input>
|
</a-input>
|
||||||
</a-input-group>
|
</a-input-group>
|
||||||
|
|||||||
@@ -26,8 +26,11 @@
|
|||||||
</a-form>
|
</a-form>
|
||||||
|
|
||||||
<!-- trojan fallbacks -->
|
<!-- 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-form v-for="(fallback, index) in inbound.settings.fallbacks" :colon="false" :label-col="{ md: {span:8} }"
|
||||||
<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>
|
: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-divider>
|
||||||
<a-form-item label='SNI'>
|
<a-form-item label='SNI'>
|
||||||
<a-input v-model="fallback.name"></a-input>
|
<a-input v-model="fallback.name"></a-input>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
{{define "form/tun"}}
|
{{define "form/tun"}}
|
||||||
<a-form :colon="false" :label-col="{ md: {span:8} }"
|
<a-form
|
||||||
:wrapper-col="{ md: {span:14} }">
|
:colon="false"
|
||||||
|
:label-col="{ md: {span:8} }"
|
||||||
|
:wrapper-col="{ md: {span:14} }"
|
||||||
|
>
|
||||||
<a-form-item>
|
<a-form-item>
|
||||||
<template slot="label">
|
<template slot="label">
|
||||||
<a-tooltip>
|
<a-tooltip>
|
||||||
@@ -11,8 +14,7 @@
|
|||||||
<a-icon type="question-circle"></a-icon>
|
<a-icon type="question-circle"></a-icon>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
</template>
|
</template>
|
||||||
<a-input v-model.trim="inbound.settings.name"
|
<a-input v-model.trim="inbound.settings.name" placeholder="xray0"></a-input>
|
||||||
placeholder="xray0"></a-input>
|
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item>
|
<a-form-item>
|
||||||
<template slot="label">
|
<template slot="label">
|
||||||
@@ -20,12 +22,42 @@
|
|||||||
<template slot="title">
|
<template slot="title">
|
||||||
<span>{{ i18n "pages.xray.tun.mtuDesc" }}</span>
|
<span>{{ i18n "pages.xray.tun.mtuDesc" }}</span>
|
||||||
</template>
|
</template>
|
||||||
MTU
|
MTU IPv4
|
||||||
<a-icon type="question-circle"></a-icon>
|
<a-icon type="question-circle"></a-icon>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
</template>
|
</template>
|
||||||
<a-input-number v-model.number="inbound.settings.mtu" :min="1"
|
<a-input-number
|
||||||
:max="9000" placeholder="1500"></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>
|
||||||
<a-form-item>
|
<a-form-item>
|
||||||
<template slot="label">
|
<template slot="label">
|
||||||
@@ -37,8 +69,26 @@
|
|||||||
<a-icon type="question-circle"></a-icon>
|
<a-icon type="question-circle"></a-icon>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
</template>
|
</template>
|
||||||
<a-input-number v-model.number="inbound.settings.userLevel" :min="0"
|
<a-input-number
|
||||||
placeholder="0"></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-item>
|
||||||
</a-form>
|
</a-form>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
<a-form-item label="Authentication">
|
<a-form-item label="Authentication">
|
||||||
<a-select v-model="inbound.settings.selectedAuth" @change="getNewVlessEnc"
|
<a-select v-model="inbound.settings.selectedAuth" @change="getNewVlessEnc"
|
||||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||||
|
<a-select-option :value="undefined">None</a-select-option>
|
||||||
<a-select-option value="X25519, not Post-Quantum">X25519 (not
|
<a-select-option value="X25519, not Post-Quantum">X25519 (not
|
||||||
Post-Quantum)</a-select-option>
|
Post-Quantum)</a-select-option>
|
||||||
<a-select-option value="ML-KEM-768, Post-Quantum">ML-KEM-768
|
<a-select-option value="ML-KEM-768, Post-Quantum">ML-KEM-768
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>{{ i18n "security" }}</th>
|
<th>{{ i18n "security" }}</th>
|
||||||
</tr>
|
</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.email ]]</td>
|
||||||
<td>[[ client.id ]]</td>
|
<td>[[ client.id ]]</td>
|
||||||
<td>[[ client.security ]]</td>
|
<td>[[ client.security ]]</td>
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
<span>{{ i18n "reset" }}</span>
|
<span>{{ i18n "reset" }}</span>
|
||||||
</template>
|
</template>
|
||||||
{{ i18n "pages.xray.wireguard.secretKey" }}
|
{{ 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>
|
</a-tooltip>
|
||||||
</template>
|
</template>
|
||||||
<a-input v-model.trim="inbound.settings.secretKey"></a-input>
|
<a-input v-model.trim="inbound.settings.secretKey"></a-input>
|
||||||
@@ -24,8 +25,11 @@
|
|||||||
<a-form-item label="Peers">
|
<a-form-item label="Peers">
|
||||||
<a-button icon="plus" type="primary" size="small" @click="inbound.settings.addPeer()"></a-button>
|
<a-button icon="plus" type="primary" size="small" @click="inbound.settings.addPeer()"></a-button>
|
||||||
</a-form-item>
|
</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-form v-for="(peer, index) in inbound.settings.peers" :colon="false" :label-col="{ md: {span:8} }"
|
||||||
<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>
|
: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-divider>
|
||||||
<a-form-item>
|
<a-form-item>
|
||||||
<template slot="label">
|
<template slot="label">
|
||||||
@@ -34,7 +38,8 @@
|
|||||||
<span>{{ i18n "reset" }}</span>
|
<span>{{ i18n "reset" }}</span>
|
||||||
</template>
|
</template>
|
||||||
{{ i18n "pages.xray.wireguard.secretKey" }}
|
{{ 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>
|
</a-tooltip>
|
||||||
</template>
|
</template>
|
||||||
<a-input v-model.trim="peer.privateKey"></a-input>
|
<a-input v-model.trim="peer.privateKey"></a-input>
|
||||||
@@ -64,7 +69,8 @@
|
|||||||
</template>
|
</template>
|
||||||
<template v-for="(aip, index) in peer.allowedIPs" :style="{ marginBottom: '10px' }">
|
<template v-for="(aip, index) in peer.allowedIPs" :style="{ marginBottom: '10px' }">
|
||||||
<a-input v-model.trim="peer.allowedIPs[index]">
|
<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>
|
</a-input>
|
||||||
</template>
|
</template>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|||||||
@@ -17,8 +17,7 @@
|
|||||||
<a-tooltip>
|
<a-tooltip>
|
||||||
<template slot="title">
|
<template slot="title">
|
||||||
<span>{{ i18n "reset" }}</span>
|
<span>{{ i18n "reset" }}</span>
|
||||||
</template> Target <a-icon @click="randomizeRealityTarget()"
|
</template> Target <a-icon @click="randomizeRealityTarget()" type="sync"></a-icon>
|
||||||
type="sync"></a-icon>
|
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
</template>
|
</template>
|
||||||
<a-input v-model.trim="inbound.stream.reality.target"></a-input>
|
<a-input v-model.trim="inbound.stream.reality.target"></a-input>
|
||||||
@@ -28,8 +27,7 @@
|
|||||||
<a-tooltip>
|
<a-tooltip>
|
||||||
<template slot="title">
|
<template slot="title">
|
||||||
<span>{{ i18n "reset" }}</span>
|
<span>{{ i18n "reset" }}</span>
|
||||||
</template> SNI <a-icon @click="randomizeRealityTarget()"
|
</template> SNI <a-icon @click="randomizeRealityTarget()" type="sync"></a-icon>
|
||||||
type="sync"></a-icon>
|
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
</template>
|
</template>
|
||||||
<a-input v-model.trim="inbound.stream.reality.serverNames"></a-input>
|
<a-input v-model.trim="inbound.stream.reality.serverNames"></a-input>
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
{{define "form/sniffing"}}
|
{{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>
|
<a-form-item>
|
||||||
<span slot="label">
|
<span slot="label">
|
||||||
{{ i18n "enabled" }}
|
{{ i18n "enabled" }}
|
||||||
@@ -15,15 +19,35 @@
|
|||||||
<template v-if="inbound.sniffing.enabled">
|
<template v-if="inbound.sniffing.enabled">
|
||||||
<a-form-item :wrapper-col="{span:24}">
|
<a-form-item :wrapper-col="{span:24}">
|
||||||
<a-checkbox-group v-model="inbound.sniffing.destOverride">
|
<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-checkbox-group>
|
||||||
</a-form-item>
|
</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-switch v-model="inbound.sniffing.metadataOnly"></a-switch>
|
||||||
</a-form-item>
|
</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-switch v-model="inbound.sniffing.routeOnly"></a-switch>
|
||||||
</a-form-item>
|
</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>
|
</template>
|
||||||
</a-form>
|
</a-form>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -1,29 +1,65 @@
|
|||||||
{{define "form/externalProxy"}}
|
{{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-divider :style="{ margin: '5px 0 0' }"></a-divider>
|
||||||
<a-form-item label="External Proxy">
|
<a-form-item label="External Proxy">
|
||||||
<a-switch v-model="externalProxy"></a-switch>
|
<a-switch v-model="externalProxy"></a-switch>
|
||||||
<a-button icon="plus" v-if="externalProxy" type="primary" :style="{ marginLeft: '10px' }" size="small"
|
<a-button
|
||||||
@click="inbound.stream.externalProxy.push({forceTls: 'same', dest: '', port: 443, remark: ''})"></a-button>
|
icon="plus"
|
||||||
|
v-if="externalProxy"
|
||||||
|
type="primary"
|
||||||
|
:style="{ marginLeft: '10px' }"
|
||||||
|
size="small"
|
||||||
|
@click="inbound.stream.externalProxy.push({forceTls: 'same', dest: '', port: 443, remark: ''})"
|
||||||
|
></a-button>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-input-group :style="{ margin: '8px 0' }" compact v-for="(row, index) in inbound.stream.externalProxy">
|
<a-input-group
|
||||||
|
:style="{ margin: '8px 0' }"
|
||||||
|
compact
|
||||||
|
v-for="(row, index) in inbound.stream.externalProxy"
|
||||||
|
>
|
||||||
<template>
|
<template>
|
||||||
<a-tooltip title="Force TLS">
|
<a-tooltip title="Force TLS">
|
||||||
<a-select v-model="row.forceTls" :style="{ width: '20%', margin: '0px' }"
|
<a-select
|
||||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
v-model="row.forceTls"
|
||||||
<a-select-option value="same">{{ i18n "pages.inbounds.same" }}</a-select-option>
|
: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="none">{{ i18n "none" }}</a-select-option>
|
||||||
<a-select-option value="tls">TLS</a-select-option>
|
<a-select-option value="tls">TLS</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
</template>
|
</template>
|
||||||
<a-input :style="{ width: '30%' }" v-model.trim="row.dest" placeholder='{{ i18n "host" }}'></a-input>
|
<a-input
|
||||||
|
:style="{ width: '30%' }"
|
||||||
|
v-model.trim="row.dest"
|
||||||
|
placeholder='{{ i18n "host" }}'
|
||||||
|
></a-input>
|
||||||
<a-tooltip title='{{ i18n "pages.inbounds.port" }}'>
|
<a-tooltip title='{{ i18n "pages.inbounds.port" }}'>
|
||||||
<a-input-number :style="{ width: '15%' }" v-model.number="row.port" min="1" max="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-tooltip>
|
||||||
<a-input :style="{ width: '30%', top: '0' }" v-model.trim="row.remark" placeholder='{{ i18n "remark" }}'>
|
<a-input
|
||||||
|
:style="{ width: '30%', top: '0' }"
|
||||||
|
v-model.trim="row.remark"
|
||||||
|
placeholder='{{ i18n "remark" }}'
|
||||||
|
>
|
||||||
<template slot="addonAfter">
|
<template slot="addonAfter">
|
||||||
<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>
|
</template>
|
||||||
</a-input>
|
</a-input>
|
||||||
</a-input-group>
|
</a-input-group>
|
||||||
|
|||||||
@@ -1,70 +1,512 @@
|
|||||||
{{define "form/streamFinalMask"}}
|
{{define "form/streamFinalMask"}}
|
||||||
|
<a-form
|
||||||
|
:colon="false"
|
||||||
|
:label-col="{ md: {span:8} }"
|
||||||
|
:wrapper-col="{ md: {span:14} }"
|
||||||
|
v-if="inbound.protocol == Protocols.HYSTERIA || ['kcp', 'xhttp', 'raw', 'tcp', 'httpupgrade', 'ws', 'grpc'].includes(inbound.stream.network)"
|
||||||
|
>
|
||||||
<a-divider :style="{ margin: '5px 0 0' }"></a-divider>
|
<a-divider :style="{ margin: '5px 0 0' }"></a-divider>
|
||||||
<a-form :colon="false" :label-col="{ md: {span:8} }"
|
|
||||||
:wrapper-col="{ md: {span:14} }">
|
<!-- TCP Masks – for raw/tcp/httpupgrade/ws/grpc/xhttp -->
|
||||||
<a-form-item label="UDP Masks">
|
<template v-if="['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp'].includes(inbound.stream.network)">
|
||||||
<a-button icon="plus" type="primary" size="small"
|
<a-form-item label="TCP Masks">
|
||||||
@click="inbound.stream.addUdpMask(inbound.stream.network === 'kcp' ? 'mkcp-aes128gcm' : 'xdns')"></a-button>
|
<a-button
|
||||||
|
icon="plus"
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
@click="inbound.stream.addTcpMask('fragment')"
|
||||||
|
></a-button>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<template
|
<template v-if="inbound.stream.finalmask.tcp && inbound.stream.finalmask.tcp.length > 0">
|
||||||
v-if="inbound.stream.finalmask.udp && inbound.stream.finalmask.udp.length > 0">
|
<a-form
|
||||||
<a-form v-for="(mask, index) in inbound.stream.finalmask.udp"
|
v-for="(mask, index) in inbound.stream.finalmask.tcp"
|
||||||
:key="index" :colon="false"
|
:key="index"
|
||||||
:label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
:colon="false"
|
||||||
<a-divider :style="{ margin: '0' }"> UDP Mask [[ index + 1 ]]
|
:label-col="{ md: {span:8} }"
|
||||||
<a-icon type="delete"
|
:wrapper-col="{ md: {span:14} }"
|
||||||
@click="() => inbound.stream.delUdpMask(index)"
|
>
|
||||||
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"></a-icon>
|
<a-divider :style="{ margin: '0' }">
|
||||||
|
TCP Mask [[ index + 1 ]]
|
||||||
|
<a-icon
|
||||||
|
type="delete"
|
||||||
|
@click="() => inbound.stream.delTcpMask(index)"
|
||||||
|
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"
|
||||||
|
></a-icon>
|
||||||
</a-divider>
|
</a-divider>
|
||||||
<a-form-item label='Type'>
|
<a-form-item label='{{ i18n "pages.xray.outbound.type" }}'>
|
||||||
<a-select v-model="mask.type"
|
<a-select
|
||||||
@change="(type) => mask.settings = mask._getDefaultSettings(type, {})"
|
v-model="mask.type"
|
||||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
@change="(type) => { mask.settings = mask._getDefaultSettings(type, {}); }"
|
||||||
<!-- mKCP-specific masks -->
|
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||||
<a-select-option v-if="inbound.stream.network === 'kcp'"
|
>
|
||||||
value="mkcp-aes128gcm">
|
<a-select-option value="fragment">Fragment</a-select-option>
|
||||||
mKCP AES-128-GCM</a-select-option>
|
<a-select-option value="header-custom">Header Custom</a-select-option>
|
||||||
<a-select-option v-if="inbound.stream.network === 'kcp'"
|
<a-select-option value="sudoku">Sudoku</a-select-option>
|
||||||
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>
|
|
||||||
<!-- xDNS for TCP/WS/HTTPUpgrade/XHTTP -->
|
|
||||||
<a-select-option
|
|
||||||
v-if="['tcp', 'ws', 'httpupgrade', 'xhttp'].includes(inbound.stream.network)"
|
|
||||||
value="xdns">
|
|
||||||
xDNS (Experimental)</a-select-option>
|
|
||||||
</a-select>
|
</a-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<!-- Settings for password-based masks -->
|
|
||||||
<a-form-item label='Password'
|
<!-- Fragment settings -->
|
||||||
v-if="['mkcp-aes128gcm'].includes(mask.type)">
|
<template v-if="mask.type === 'fragment'">
|
||||||
<a-input v-model.trim="mask.settings.password"
|
<a-form-item label="Packets">
|
||||||
placeholder="Obfuscation password"></a-input>
|
<a-select
|
||||||
|
v-model="mask.settings.packets"
|
||||||
|
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||||
|
>
|
||||||
|
<a-select-option value="tlshello">tlshello</a-select-option>
|
||||||
|
<a-select-option value="1-3">1-3</a-select-option>
|
||||||
|
<a-select-option value="1-5">1-5</a-select-option>
|
||||||
|
</a-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<!-- Settings for domain-based masks -->
|
<a-form-item label="Length">
|
||||||
<a-form-item label='Domain'
|
<a-input v-model.trim="mask.settings.length" placeholder="e.g. 100-200" />
|
||||||
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>
|
</a-form-item>
|
||||||
|
<a-form-item label="Delay">
|
||||||
|
<a-input v-model.trim="mask.settings.delay" placeholder="e.g. 10-20" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Max Split">
|
||||||
|
<a-input v-model.trim="mask.settings.maxSplit" placeholder="e.g. 3-6" />
|
||||||
|
</a-form-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Sudoku settings (TCP) -->
|
||||||
|
<template v-if="mask.type === 'sudoku'">
|
||||||
|
<a-form-item label="Password">
|
||||||
|
<a-input v-model.trim="mask.settings.password" placeholder="Obfuscation password" />
|
||||||
|
</a-form-item>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Header Custom (TCP) – clients/servers/errors are 2D arrays of groups -->
|
||||||
|
<template v-if="mask.type === 'header-custom'">
|
||||||
|
<!-- Clients -->
|
||||||
|
<a-form-item label="Clients">
|
||||||
|
<a-button
|
||||||
|
icon="plus"
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
@click="mask.settings.clients.push([{delay: 0, rand: 0, randRange: '0-255', type: 'array', packet: []}])"
|
||||||
|
></a-button>
|
||||||
|
</a-form-item>
|
||||||
|
<template v-for="(group, gi) in mask.settings.clients" :key="'cg'+gi">
|
||||||
|
<a-divider :style="{ margin: '0' }">
|
||||||
|
Clients Group [[ gi + 1 ]]
|
||||||
|
<a-icon
|
||||||
|
type="delete"
|
||||||
|
@click="mask.settings.clients.splice(gi, 1)"
|
||||||
|
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
|
||||||
|
></a-icon>
|
||||||
|
</a-divider>
|
||||||
|
<template v-for="(item, ii) in group" :key="'ci'+ii">
|
||||||
|
<a-form-item label='{{ i18n "pages.xray.outbound.type" }}'>
|
||||||
|
<a-select v-model="item.type" :dropdown-class-name="themeSwitcher.currentTheme"
|
||||||
|
@change="t => { if(t === 'base64') item.packet = RandomUtil.randomBase64(); else if(t === 'array') { item.rand = 0; item.packet = []; } else { item.packet = ''; } }">
|
||||||
|
<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="Delay (ms)">
|
||||||
|
<a-input-number v-model.number="item.delay" :min="0" />
|
||||||
|
</a-form-item>
|
||||||
|
<template v-if="item.type === 'array'">
|
||||||
|
<a-form-item label="Rand">
|
||||||
|
<a-input-number v-model.number="item.rand" :min="0" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Rand Range">
|
||||||
|
<a-input v-model.trim="item.randRange" placeholder="0-255" />
|
||||||
|
</a-form-item>
|
||||||
|
</template>
|
||||||
|
<a-form-item v-else label="Packet">
|
||||||
|
<a-input-group compact v-if="item.type === 'base64'">
|
||||||
|
<a-input v-model.trim="item.packet" placeholder="binary data" :style="{ width: 'calc(100% - 32px)' }" />
|
||||||
|
<a-button icon="reload" @click="item.packet = RandomUtil.randomBase64()" />
|
||||||
|
</a-input-group>
|
||||||
|
<a-input v-else v-model.trim="item.packet" placeholder="binary data" />
|
||||||
|
</a-form-item>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Servers -->
|
||||||
|
<a-form-item label="Servers">
|
||||||
|
<a-button
|
||||||
|
icon="plus"
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
@click="mask.settings.servers.push([{delay: 0, rand: 0, randRange: '0-255', type: 'array', packet: []}])"
|
||||||
|
></a-button>
|
||||||
|
</a-form-item>
|
||||||
|
<template v-for="(group, gi) in mask.settings.servers" :key="'sg'+gi">
|
||||||
|
<a-divider :style="{ margin: '0' }">
|
||||||
|
Servers Group [[ gi + 1 ]]
|
||||||
|
<a-icon
|
||||||
|
type="delete"
|
||||||
|
@click="mask.settings.servers.splice(gi, 1)"
|
||||||
|
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
|
||||||
|
></a-icon>
|
||||||
|
</a-divider>
|
||||||
|
<template v-for="(item, ii) in group" :key="'si'+ii">
|
||||||
|
<a-form-item label='{{ i18n "pages.xray.outbound.type" }}'>
|
||||||
|
<a-select v-model="item.type" :dropdown-class-name="themeSwitcher.currentTheme"
|
||||||
|
@change="t => { if(t === 'base64') item.packet = RandomUtil.randomBase64(); else if(t === 'array') { item.rand = 0; item.packet = []; } else { item.packet = ''; } }">
|
||||||
|
<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="Delay (ms)">
|
||||||
|
<a-input-number v-model.number="item.delay" :min="0" />
|
||||||
|
</a-form-item>
|
||||||
|
<template v-if="item.type === 'array'">
|
||||||
|
<a-form-item label="Rand">
|
||||||
|
<a-input-number v-model.number="item.rand" :min="0" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Rand Range">
|
||||||
|
<a-input v-model.trim="item.randRange" placeholder="0-255" />
|
||||||
|
</a-form-item>
|
||||||
|
</template>
|
||||||
|
<a-form-item v-else label="Packet">
|
||||||
|
<a-input-group compact v-if="item.type === 'base64'">
|
||||||
|
<a-input v-model.trim="item.packet" placeholder="binary data" :style="{ width: 'calc(100% - 32px)' }" />
|
||||||
|
<a-button icon="reload" @click="item.packet = RandomUtil.randomBase64()" />
|
||||||
|
</a-input-group>
|
||||||
|
<a-input v-else v-model.trim="item.packet" placeholder="binary data" />
|
||||||
|
</a-form-item>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
</a-form>
|
</a-form>
|
||||||
</template>
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="inbound.protocol == Protocols.HYSTERIA || inbound.stream.network == 'kcp'">
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item
|
||||||
|
label="Password"
|
||||||
|
v-if="['mkcp-aes128gcm', 'salamander'].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 === '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-button
|
||||||
|
icon="plus"
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
@click="mask.settings.noise.push({rand: '1-8192', randRange: '0-255', type: 'array', packet: [], delay: '10-20'})"
|
||||||
|
></a-button>
|
||||||
|
</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='{{ i18n "pages.xray.outbound.type" }}'>
|
||||||
|
<a-select
|
||||||
|
v-model="n.type"
|
||||||
|
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||||
|
@change="t => { if(t === 'base64') n.packet = RandomUtil.randomBase64(); else if(t === 'array') n.packet = []; else n.packet = ''; }"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<template v-if="n.type === 'array'">
|
||||||
|
<a-form-item label="Rand">
|
||||||
|
<a-input v-model.trim="n.rand" placeholder="0 or 1-8192" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Rand Range">
|
||||||
|
<a-input v-model.trim="n.randRange" placeholder="0-255" />
|
||||||
|
</a-form-item>
|
||||||
|
</template>
|
||||||
|
<a-form-item v-else label="Packet">
|
||||||
|
<a-input-group compact v-if="n.type === 'base64'">
|
||||||
|
<a-input v-model.trim="n.packet" placeholder="binary data" :style="{ width: 'calc(100% - 32px)' }" />
|
||||||
|
<a-button icon="reload" @click="n.packet = RandomUtil.randomBase64()" />
|
||||||
|
</a-input-group>
|
||||||
|
<a-input v-else 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 === 'header-custom'">
|
||||||
|
<a-form-item label="Client">
|
||||||
|
<a-button
|
||||||
|
icon="plus"
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
@click="mask.settings.client.push({rand: 0, randRange: '0-255', type: 'array', packet: []})"
|
||||||
|
></a-button>
|
||||||
|
</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='{{ i18n "pages.xray.outbound.type" }}'>
|
||||||
|
<a-select
|
||||||
|
v-model="c.type"
|
||||||
|
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||||
|
@change="t => { if(t === 'base64') c.packet = RandomUtil.randomBase64(); else if(t === 'array') c.packet = []; else c.packet = ''; }"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<template v-if="c.type === 'array'">
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
|
<a-form-item v-else label="Packet">
|
||||||
|
<a-input-group compact v-if="c.type === 'base64'">
|
||||||
|
<a-input v-model.trim="c.packet" placeholder="binary data" :style="{ width: 'calc(100% - 32px)' }" />
|
||||||
|
<a-button icon="reload" @click="c.packet = RandomUtil.randomBase64()" />
|
||||||
|
</a-input-group>
|
||||||
|
<a-input v-else 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-button
|
||||||
|
icon="plus"
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
@click="mask.settings.server.push({rand: 0, randRange: '0-255', type: 'array', packet: []})"
|
||||||
|
></a-button>
|
||||||
|
</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='{{ i18n "pages.xray.outbound.type" }}'>
|
||||||
|
<a-select
|
||||||
|
v-model="s.type"
|
||||||
|
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||||
|
@change="t => { if(t === 'base64') s.packet = RandomUtil.randomBase64(); else if(t === 'array') s.packet = []; else s.packet = ''; }"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<template v-if="s.type === 'array'">
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
|
<a-form-item v-else label="Packet">
|
||||||
|
<a-input-group compact v-if="s.type === 'base64'">
|
||||||
|
<a-input v-model.trim="s.packet" placeholder="binary data" :style="{ width: 'calc(100% - 32px)' }" />
|
||||||
|
<a-button icon="reload" @click="s.packet = RandomUtil.randomBase64()" />
|
||||||
|
</a-input-group>
|
||||||
|
<a-input v-else v-model.trim="s.packet" placeholder="binary data" />
|
||||||
|
</a-form-item>
|
||||||
|
</template>
|
||||||
|
</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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- quicParams – only for xhttp H3 and hysteria -->
|
||||||
|
<template v-if="inbound.protocol == Protocols.HYSTERIA || inbound.stream.network == 'xhttp'">
|
||||||
|
<a-form-item label="QUIC Params">
|
||||||
|
<a-switch v-model="inbound.stream.finalmask.enableQuicParams"></a-switch>
|
||||||
|
</a-form-item>
|
||||||
|
<template v-if="inbound.stream.finalmask.enableQuicParams">
|
||||||
|
<a-form-item label="Congestion">
|
||||||
|
<a-select
|
||||||
|
v-model="inbound.stream.finalmask.quicParams.congestion"
|
||||||
|
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||||
|
>
|
||||||
|
<a-select-option value="reno">Reno</a-select-option>
|
||||||
|
<a-select-option value="bbr">BBR</a-select-option>
|
||||||
|
<a-select-option value="brutal">Brutal</a-select-option>
|
||||||
|
<a-select-option value="force-brutal">Force Brutal</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Debug">
|
||||||
|
<a-switch v-model="inbound.stream.finalmask.quicParams.debug"></a-switch>
|
||||||
|
</a-form-item>
|
||||||
|
<template v-if="['brutal','force-brutal'].includes(inbound.stream.finalmask.quicParams.congestion)">
|
||||||
|
<a-form-item label="Brutal Up">
|
||||||
|
<a-input v-model.trim="inbound.stream.finalmask.quicParams.brutalUp" placeholder="e.g. 60 mbps" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Brutal Down">
|
||||||
|
<a-input v-model.trim="inbound.stream.finalmask.quicParams.brutalDown" placeholder="e.g. 60 mbps" />
|
||||||
|
</a-form-item>
|
||||||
|
</template>
|
||||||
|
<a-form-item label="UDP Hop">
|
||||||
|
<a-switch v-model="inbound.stream.finalmask.quicParams.hasUdpHop"></a-switch>
|
||||||
|
</a-form-item>
|
||||||
|
<template v-if="inbound.stream.finalmask.quicParams.hasUdpHop">
|
||||||
|
<a-form-item label="Hop Ports">
|
||||||
|
<a-input v-model.trim="inbound.stream.finalmask.quicParams.udpHop.ports" placeholder="e.g. 20000-50000" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Hop Interval (s)">
|
||||||
|
<a-input-number v-model.number="inbound.stream.finalmask.quicParams.udpHop.interval" :min="5" />
|
||||||
|
</a-form-item>
|
||||||
|
</template>
|
||||||
|
<a-form-item label="Max Idle Timeout (s)">
|
||||||
|
<a-input-number v-model.number="inbound.stream.finalmask.quicParams.maxIdleTimeout" :min="4" :max="120" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Keep Alive Period (s)">
|
||||||
|
<a-input-number v-model.number="inbound.stream.finalmask.quicParams.keepAlivePeriod" :min="0" :max="60" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Disable Path MTU Dis">
|
||||||
|
<a-switch v-model="inbound.stream.finalmask.quicParams.disablePathMTUDiscovery"></a-switch>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Max Incoming Streams">
|
||||||
|
<a-input-number v-model.number="inbound.stream.finalmask.quicParams.maxIncomingStreams" :min="0" placeholder="0 = default" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Init Stream Window">
|
||||||
|
<a-input-number v-model.number="inbound.stream.finalmask.quicParams.initStreamReceiveWindow" :min="0" placeholder="0 = default" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Max Stream Window">
|
||||||
|
<a-input-number v-model.number="inbound.stream.finalmask.quicParams.maxStreamReceiveWindow" :min="0" placeholder="0 = default" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Init Conn Window">
|
||||||
|
<a-input-number v-model.number="inbound.stream.finalmask.quicParams.initConnectionReceiveWindow" :min="0" placeholder="0 = default" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Max Conn Window">
|
||||||
|
<a-input-number v-model.number="inbound.stream.finalmask.quicParams.maxConnectionReceiveWindow" :min="0" placeholder="0 = default" />
|
||||||
|
</a-form-item>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
</a-form>
|
</a-form>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
{{define "form/streamGRPC"}}
|
{{define "form/streamGRPC"}}
|
||||||
<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="Service Name">
|
<a-form-item label="Service Name">
|
||||||
<a-input v-model.trim="inbound.stream.grpc.serviceName"></a-input>
|
<a-input v-model.trim="inbound.stream.grpc.serviceName"></a-input>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
{{define "form/streamHTTPUpgrade"}}
|
{{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-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>
|
||||||
<a-form-item label='{{ i18n "host" }}'>
|
<a-form-item label='{{ i18n "host" }}'>
|
||||||
<a-input v-model.trim="inbound.stream.httpupgrade.host"></a-input>
|
<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-input v-model.trim="inbound.stream.httpupgrade.path"></a-input>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label='{{ i18n "pages.inbounds.stream.tcp.requestHeader" }}'>
|
<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>
|
||||||
<a-form-item :wrapper-col="{span:24}">
|
<a-form-item :wrapper-col="{span:24}">
|
||||||
<a-input-group compact v-for="(header, index) in inbound.stream.httpupgrade.headers">
|
<a-input-group
|
||||||
<a-input :style="{ width: '50%' }" v-model.trim="header.name" placeholder='{{ i18n "pages.inbounds.stream.general.name"}}'>
|
compact
|
||||||
<template slot="addonBefore" :style="{ margin: '0' }">[[ index+1 ]]</template>
|
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>
|
||||||
<a-input :style="{ width: '50%' }" v-model.trim="header.value" placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'>
|
<a-input
|
||||||
<a-button icon="minus" slot="addonAfter" size="small" @click="inbound.stream.httpupgrade.removeHeader(index)"></a-button>
|
: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>
|
||||||
</a-input-group>
|
</a-input-group>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
{{define "form/streamHysteria"}}
|
||||||
|
<a-form
|
||||||
|
:colon="false"
|
||||||
|
:label-col="{ md: {span:8} }"
|
||||||
|
:wrapper-col="{ md: {span:14} }"
|
||||||
|
>
|
||||||
|
<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"}}
|
{{define "form/streamKCP"}}
|
||||||
<a-form :colon="false" :label-col="{ md: {span:8} }"
|
<a-form
|
||||||
:wrapper-col="{ md: {span:14} }">
|
:colon="false"
|
||||||
<a-form-item label='MTU'>
|
:label-col="{ md: {span:8} }"
|
||||||
<a-input-number v-model.number="inbound.stream.kcp.mtu" :min="576"
|
:wrapper-col="{ md: {span:14} }"
|
||||||
:max="1460"></a-input-number>
|
>
|
||||||
|
<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>
|
||||||
<a-form-item label='TTI (ms)'>
|
<a-form-item label="TTI (ms)">
|
||||||
<a-input-number v-model.number="inbound.stream.kcp.tti" :min="10"
|
<a-input-number
|
||||||
:max="100"></a-input-number>
|
v-model.number="inbound.stream.kcp.tti"
|
||||||
|
:min="10"
|
||||||
|
:max="100"
|
||||||
|
></a-input-number>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label='Uplink (MB/s)'>
|
<a-form-item label="Uplink (MB/s)">
|
||||||
<a-input-number v-model.number="inbound.stream.kcp.upCap"
|
<a-input-number
|
||||||
:min="0"></a-input-number>
|
v-model.number="inbound.stream.kcp.upCap"
|
||||||
|
:min="0"
|
||||||
|
></a-input-number>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label='Downlink (MB/s)'>
|
<a-form-item label="Downlink (MB/s)">
|
||||||
<a-input-number v-model.number="inbound.stream.kcp.downCap"
|
<a-input-number
|
||||||
:min="0"></a-input-number>
|
v-model.number="inbound.stream.kcp.downCap"
|
||||||
|
:min="0"
|
||||||
|
></a-input-number>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label='Congestion'>
|
<a-form-item label="CWND Multiplier">
|
||||||
<a-switch v-model="inbound.stream.kcp.congestion"></a-switch>
|
<a-input-number
|
||||||
|
v-model.number="inbound.stream.kcp.cwndMultiplier"
|
||||||
|
:min="1"
|
||||||
|
></a-input-number>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label='Read Buffer (MB)'>
|
<a-form-item label="Max Sending Window">
|
||||||
<a-input-number v-model.number="inbound.stream.kcp.readBuffer"
|
<a-input-number
|
||||||
:min="0"></a-input-number>
|
v-model.number="inbound.stream.kcp.maxSendingWindow"
|
||||||
</a-form-item>
|
:min="0"
|
||||||
<a-form-item label='Write Buffer (MB)'>
|
></a-input-number>
|
||||||
<a-input-number v-model.number="inbound.stream.kcp.writeBuffer"
|
|
||||||
:min="0"></a-input-number>
|
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-form>
|
</a-form>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
{{define "form/streamSettings"}}
|
{{define "form/streamSettings"}}
|
||||||
<!-- select stream network -->
|
<!-- select stream network -->
|
||||||
<a-form :colon="false" :label-col="{ md: {span:8} }"
|
<a-form
|
||||||
:wrapper-col="{ md: {span:14} }">
|
: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-form-item label='{{ i18n "transmission" }}'>
|
||||||
<a-select v-model="inbound.stream.network" :style="{ width: '75%' }"
|
<a-select
|
||||||
|
v-model="inbound.stream.network"
|
||||||
|
:style="{ width: '75%' }"
|
||||||
@change="streamNetworkChange"
|
@change="streamNetworkChange"
|
||||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||||
|
>
|
||||||
<a-select-option value="tcp">TCP (RAW)</a-select-option>
|
<a-select-option value="tcp">TCP (RAW)</a-select-option>
|
||||||
<a-select-option value="kcp">mKCP</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="ws">WebSocket</a-select-option>
|
||||||
@@ -36,6 +43,11 @@
|
|||||||
{{template "form/streamGRPC"}}
|
{{template "form/streamGRPC"}}
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- hysteria -->
|
||||||
|
<template v-if="inbound.stream.network === 'hysteria'">
|
||||||
|
{{template "form/streamHysteria"}}
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- httpupgrade -->
|
<!-- httpupgrade -->
|
||||||
<template v-if="inbound.stream.network === 'httpupgrade'">
|
<template v-if="inbound.stream.network === 'httpupgrade'">
|
||||||
{{template "form/streamHTTPUpgrade"}}
|
{{template "form/streamHTTPUpgrade"}}
|
||||||
@@ -47,13 +59,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- sockopt -->
|
<!-- sockopt -->
|
||||||
<template>
|
<template> {{template "form/streamSockopt"}} </template>
|
||||||
{{template "form/streamSockopt"}}
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- finalmask - only for TCP, WS, HTTPUpgrade, XHTTP, mKCP -->
|
<!-- finalmask -->
|
||||||
<template
|
<template> {{template "form/streamFinalMask"}} </template>
|
||||||
v-if="['tcp', 'ws', 'httpupgrade', 'xhttp', 'kcp'].includes(inbound.stream.network)">
|
|
||||||
{{template "form/streamFinalMask"}}
|
|
||||||
</template>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -1,27 +1,49 @@
|
|||||||
{{define "form/streamSockopt"}}
|
{{define "form/streamSockopt"}}
|
||||||
<a-divider :style="{ margin: '5px 0 0' }"></a-divider>
|
<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
|
||||||
|
:colon="false"
|
||||||
|
:label-col="{ md: {span:8} }"
|
||||||
|
:wrapper-col="{ md: {span:14} }"
|
||||||
|
>
|
||||||
<a-form-item label="Sockopt">
|
<a-form-item label="Sockopt">
|
||||||
<a-switch v-model="inbound.stream.sockoptSwitch"></a-switch>
|
<a-switch v-model="inbound.stream.sockoptSwitch"></a-switch>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<template v-if="inbound.stream.sockoptSwitch">
|
<template v-if="inbound.stream.sockoptSwitch">
|
||||||
<a-form-item label="Route Mark">
|
<a-form-item label="Route Mark">
|
||||||
<a-input-number v-model.number="inbound.stream.sockopt.mark" :min="0"></a-input-number>
|
<a-input-number
|
||||||
|
v-model.number="inbound.stream.sockopt.mark"
|
||||||
|
:min="0"
|
||||||
|
></a-input-number>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="TCP Keep Alive Interval">
|
<a-form-item label="TCP Keep Alive Interval">
|
||||||
<a-input-number v-model.number="inbound.stream.sockopt.tcpKeepAliveInterval" :min="0"></a-input-number>
|
<a-input-number
|
||||||
|
v-model.number="inbound.stream.sockopt.tcpKeepAliveInterval"
|
||||||
|
:min="0"
|
||||||
|
></a-input-number>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="TCP Keep Alive Idle">
|
<a-form-item label="TCP Keep Alive Idle">
|
||||||
<a-input-number v-model.number="inbound.stream.sockopt.tcpKeepAliveIdle" :min="0"></a-input-number>
|
<a-input-number
|
||||||
|
v-model.number="inbound.stream.sockopt.tcpKeepAliveIdle"
|
||||||
|
:min="0"
|
||||||
|
></a-input-number>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="TCP Max Seg">
|
<a-form-item label="TCP Max Seg">
|
||||||
<a-input-number v-model.number="inbound.stream.sockopt.tcpMaxSeg" :min="0"></a-input-number>
|
<a-input-number
|
||||||
|
v-model.number="inbound.stream.sockopt.tcpMaxSeg"
|
||||||
|
:min="0"
|
||||||
|
></a-input-number>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="TCP User Timeout">
|
<a-form-item label="TCP User Timeout">
|
||||||
<a-input-number v-model.number="inbound.stream.sockopt.tcpUserTimeout" :min="0"></a-input-number>
|
<a-input-number
|
||||||
|
v-model.number="inbound.stream.sockopt.tcpUserTimeout"
|
||||||
|
:min="0"
|
||||||
|
></a-input-number>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="TCP Window Clamp">
|
<a-form-item label="TCP Window Clamp">
|
||||||
<a-input-number v-model.number="inbound.stream.sockopt.tcpWindowClamp" :min="0"></a-input-number>
|
<a-input-number
|
||||||
|
v-model.number="inbound.stream.sockopt.tcpWindowClamp"
|
||||||
|
:min="0"
|
||||||
|
></a-input-number>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="Proxy Protocol">
|
<a-form-item label="Proxy Protocol">
|
||||||
<a-switch v-model="inbound.stream.sockopt.acceptProxyProtocol"></a-switch>
|
<a-switch v-model="inbound.stream.sockopt.acceptProxyProtocol"></a-switch>
|
||||||
@@ -38,18 +60,34 @@
|
|||||||
<a-form-item label="V6 Only">
|
<a-form-item label="V6 Only">
|
||||||
<a-switch v-model.trim="inbound.stream.sockopt.V6Only"></a-switch>
|
<a-switch v-model.trim="inbound.stream.sockopt.V6Only"></a-switch>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label='Domain Strategy'>
|
<a-form-item label="Domain Strategy">
|
||||||
<a-select v-model="inbound.stream.sockopt.domainStrategy" :style="{ width: '50%' }" :dropdown-class-name="themeSwitcher.currentTheme">
|
<a-select
|
||||||
<a-select-option v-for="key in DOMAIN_STRATEGY_OPTION" :value="key">[[ key ]]</a-select-option>
|
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-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label='TCP Congestion'>
|
<a-form-item label="TCP Congestion">
|
||||||
<a-select v-model="inbound.stream.sockopt.tcpcongestion" :style="{ width: '50%' }" :dropdown-class-name="themeSwitcher.currentTheme">
|
<a-select
|
||||||
<a-select-option v-for="key in TCP_CONGESTION_OPTION" :value="key">[[ key ]]</a-select-option>
|
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-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="TProxy">
|
<a-form-item label="TProxy">
|
||||||
<a-select v-model="inbound.stream.sockopt.tproxy" :style="{ width: '50%' }" :dropdown-class-name="themeSwitcher.currentTheme">
|
<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="off">Off</a-select-option>
|
||||||
<a-select-option value="redirect">Redirect</a-select-option>
|
<a-select-option value="redirect">Redirect</a-select-option>
|
||||||
<a-select-option value="tproxy">TProxy</a-select-option>
|
<a-select-option value="tproxy">TProxy</a-select-option>
|
||||||
@@ -62,9 +100,15 @@
|
|||||||
<a-input v-model="inbound.stream.sockopt.interfaceName"></a-input>
|
<a-input v-model="inbound.stream.sockopt.interfaceName"></a-input>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="Trusted X-Forwarded-For">
|
<a-form-item label="Trusted X-Forwarded-For">
|
||||||
<a-select mode="tags" v-model="inbound.stream.sockopt.trustedXForwardedFor" :style="{ width: '100%' }"
|
<a-select
|
||||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
mode="tags"
|
||||||
<a-select-option value="CF-Connecting-IP">CF-Connecting-IP</a-select-option>
|
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="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="True-Client-IP">True-Client-IP</a-select-option>
|
||||||
<a-select-option value="X-Client-IP">X-Client-IP</a-select-option>
|
<a-select-option value="X-Client-IP">X-Client-IP</a-select-option>
|
||||||
|
|||||||
@@ -1,17 +1,31 @@
|
|||||||
{{define "form/streamTCP"}}
|
{{define "form/streamTCP"}}
|
||||||
<!-- tcp type -->
|
<!-- 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-form-item label="Proxy Protocol" v-if="inbound.canEnableTls()">
|
||||||
<a-switch v-model="inbound.stream.tcp.acceptProxyProtocol"></a-switch>
|
<a-switch v-model="inbound.stream.tcp.acceptProxyProtocol"></a-switch>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label='HTTP {{ i18n "camouflage" }}'>
|
<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-item>
|
||||||
</a-form>
|
</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 -->
|
<!-- 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-form-item label='{{ i18n "pages.inbounds.stream.tcp.version" }}'>
|
||||||
<a-input v-model.trim="inbound.stream.tcp.request.version"></a-input>
|
<a-input v-model.trim="inbound.stream.tcp.request.version"></a-input>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
@@ -19,31 +33,66 @@
|
|||||||
<a-input v-model.trim="inbound.stream.tcp.request.method"></a-input>
|
<a-input v-model.trim="inbound.stream.tcp.request.method"></a-input>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item>
|
<a-form-item>
|
||||||
<template slot="label">{{ i18n "pages.inbounds.stream.tcp.path" }}
|
<template slot="label"
|
||||||
<a-button icon="plus" size="small" @click="inbound.stream.tcp.request.addPath('/')"></a-button>
|
>{{ i18n "pages.inbounds.stream.tcp.path" }}
|
||||||
|
<a-button
|
||||||
|
icon="plus"
|
||||||
|
size="small"
|
||||||
|
@click="inbound.stream.tcp.request.addPath('/')"
|
||||||
|
></a-button>
|
||||||
</template>
|
</template>
|
||||||
<template v-for="(path, index) in inbound.stream.tcp.request.path">
|
<template v-for="(path, index) in inbound.stream.tcp.request.path">
|
||||||
<a-input v-model.trim="inbound.stream.tcp.request.path[index]">
|
<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>
|
</a-input>
|
||||||
</template>
|
</template>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label='{{ i18n "pages.inbounds.stream.tcp.requestHeader" }}'>
|
<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>
|
||||||
<a-form-item :wrapper-col="{span:24}">
|
<a-form-item :wrapper-col="{span:24}">
|
||||||
<a-input-group compact v-for="(header, index) in inbound.stream.tcp.request.headers">
|
<a-input-group
|
||||||
<a-input :style="{ width: '50%' }" v-model.trim="header.name" placeholder='{{ i18n "pages.inbounds.stream.general.name" }}'>
|
compact
|
||||||
<template slot="addonBefore" :style="{ margin: '0' }">[[ index+1 ]]</template>
|
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>
|
||||||
<a-input :style="{ width: '50%' }" v-model.trim="header.value" placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'>
|
<a-input
|
||||||
<a-button icon="minus" slot="addonAfter" size="small" @click="inbound.stream.tcp.request.removeHeader(index)"></a-button>
|
: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>
|
||||||
</a-input-group>
|
</a-input-group>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
<!-- tcp response -->
|
<!-- 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-form-item label='{{ i18n "pages.inbounds.stream.tcp.version" }}'>
|
||||||
<a-input v-model.trim="inbound.stream.tcp.response.version"></a-input>
|
<a-input v-model.trim="inbound.stream.tcp.response.version"></a-input>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
@@ -54,16 +103,37 @@
|
|||||||
<a-input v-model.trim="inbound.stream.tcp.response.reason"></a-input>
|
<a-input v-model.trim="inbound.stream.tcp.response.reason"></a-input>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label='{{ i18n "pages.inbounds.stream.tcp.responseHeader" }}'>
|
<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>
|
||||||
<a-form-item :wrapper-col="{span:24}">
|
<a-form-item :wrapper-col="{span:24}">
|
||||||
<a-input-group compact v-for="(header, index) in inbound.stream.tcp.response.headers">
|
<a-input-group
|
||||||
<a-input :style="{ width: '50%' }" v-model.trim="header.name" placeholder='{{ i18n "pages.inbounds.stream.general.name" }}'>
|
compact
|
||||||
<template slot="addonBefore" :style="{ margin: '0' }">[[ index+1 ]]</template>
|
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>
|
||||||
<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">
|
<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>
|
</template>
|
||||||
</a-input>
|
</a-input>
|
||||||
</a-input-group>
|
</a-input-group>
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
{{define "form/streamWS"}}
|
{{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-form-item label="Proxy Protocol">
|
||||||
<a-switch v-model="inbound.stream.ws.acceptProxyProtocol"></a-switch>
|
<a-switch v-model="inbound.stream.ws.acceptProxyProtocol"></a-switch>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
@@ -9,19 +13,41 @@
|
|||||||
<a-form-item label='{{ i18n "path" }}'>
|
<a-form-item label='{{ i18n "path" }}'>
|
||||||
<a-input v-model.trim="inbound.stream.ws.path"></a-input>
|
<a-input v-model.trim="inbound.stream.ws.path"></a-input>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label='Heartbeat Period'>
|
<a-form-item label="Heartbeat Period">
|
||||||
<a-input-number v-model.number="inbound.stream.ws.heartbeatPeriod" :min="0"></a-input-number>
|
<a-input-number
|
||||||
|
v-model.number="inbound.stream.ws.heartbeatPeriod"
|
||||||
|
:min="0"
|
||||||
|
></a-input-number>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label='{{ i18n "pages.inbounds.stream.tcp.requestHeader" }}'>
|
<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>
|
||||||
<a-form-item :wrapper-col="{span:24}">
|
<a-form-item :wrapper-col="{span:24}">
|
||||||
<a-input-group compact v-for="(header, index) in inbound.stream.ws.headers">
|
<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"}}'>
|
<a-input
|
||||||
<template slot="addonBefore" :style="{ margin: '0' }">[[ index+1 ]]</template>
|
: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>
|
||||||
<a-input :style="{ width: '50%' }" v-model.trim="header.value" placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'>
|
<a-input
|
||||||
<a-button icon="minus" slot="addonAfter" size="small" @click="inbound.stream.ws.removeHeader(index)"></a-button>
|
: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>
|
||||||
</a-input-group>
|
</a-input-group>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
{{define "form/streamXHTTP"}}
|
{{define "form/streamXHTTP"}}
|
||||||
<a-form :colon="false" :label-col="{ md: {span:8} }"
|
<a-form
|
||||||
:wrapper-col="{ md: {span:14} }">
|
:colon="false"
|
||||||
|
:label-col="{ md: {span:8} }"
|
||||||
|
:wrapper-col="{ md: {span:14} }"
|
||||||
|
>
|
||||||
<a-form-item label='{{ i18n "host" }}'>
|
<a-form-item label='{{ i18n "host" }}'>
|
||||||
<a-input v-model.trim="inbound.stream.xhttp.host"></a-input>
|
<a-input v-model.trim="inbound.stream.xhttp.host"></a-input>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
@@ -8,45 +11,70 @@
|
|||||||
<a-input v-model.trim="inbound.stream.xhttp.path"></a-input>
|
<a-input v-model.trim="inbound.stream.xhttp.path"></a-input>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label='{{ i18n "pages.inbounds.stream.tcp.requestHeader" }}'>
|
<a-form-item label='{{ i18n "pages.inbounds.stream.tcp.requestHeader" }}'>
|
||||||
<a-button icon="plus" size="small"
|
<a-button
|
||||||
@click="inbound.stream.xhttp.addHeader('', '')"></a-button>
|
icon="plus"
|
||||||
|
size="small"
|
||||||
|
@click="inbound.stream.xhttp.addHeader('', '')"
|
||||||
|
></a-button>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item :wrapper-col="{span:24}">
|
<a-form-item :wrapper-col="{span:24}">
|
||||||
<a-input-group compact
|
<a-input-group
|
||||||
v-for="(header, index) in inbound.stream.xhttp.headers">
|
compact
|
||||||
<a-input :style="{ width: '50%' }" v-model.trim="header.name"
|
v-for="(header, index) in inbound.stream.xhttp.headers"
|
||||||
placeholder='{{ i18n "pages.inbounds.stream.general.name"}}'>
|
>
|
||||||
<template slot="addonBefore" :style="{ margin: '0' }">[[ index+1
|
<a-input
|
||||||
]]</template>
|
: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>
|
||||||
<a-input :style="{ width: '50%' }" v-model.trim="header.value"
|
<a-input
|
||||||
placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'>
|
:style="{ width: '50%' }"
|
||||||
<a-button icon="minus" slot="addonAfter" size="small"
|
v-model.trim="header.value"
|
||||||
@click="inbound.stream.xhttp.removeHeader(index)"></a-button>
|
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>
|
||||||
</a-input-group>
|
</a-input-group>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label='Mode'>
|
<a-form-item label="Mode">
|
||||||
<a-select v-model="inbound.stream.xhttp.mode" :style="{ width: '50%' }"
|
<a-select
|
||||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
v-model="inbound.stream.xhttp.mode"
|
||||||
<a-select-option v-for="key in MODE_OPTION" :value="key">[[ key
|
:style="{ width: '50%' }"
|
||||||
]]</a-select-option>
|
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||||
|
>
|
||||||
|
<a-select-option v-for="key in MODE_OPTION" :value="key"
|
||||||
|
>[[ key ]]</a-select-option
|
||||||
|
>
|
||||||
</a-select>
|
</a-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="Max Buffered Upload"
|
<a-form-item
|
||||||
v-if="inbound.stream.xhttp.mode === 'packet-up'">
|
label="Max Buffered Upload"
|
||||||
|
v-if="inbound.stream.xhttp.mode === 'packet-up'"
|
||||||
|
>
|
||||||
<a-input-number
|
<a-input-number
|
||||||
v-model.number="inbound.stream.xhttp.scMaxBufferedPosts"></a-input-number>
|
v-model.number="inbound.stream.xhttp.scMaxBufferedPosts"
|
||||||
|
></a-input-number>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="Max Upload Size (Byte)"
|
<a-form-item
|
||||||
v-if="inbound.stream.xhttp.mode === 'packet-up'">
|
label="Max Upload Size (Byte)"
|
||||||
<a-input
|
v-if="inbound.stream.xhttp.mode === 'packet-up'"
|
||||||
v-model.trim="inbound.stream.xhttp.scMaxEachPostBytes"></a-input>
|
>
|
||||||
|
<a-input v-model.trim="inbound.stream.xhttp.scMaxEachPostBytes"></a-input>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="Stream-Up Server"
|
<a-form-item
|
||||||
v-if="inbound.stream.xhttp.mode === 'stream-up'">
|
label="Stream-Up Server"
|
||||||
<a-input
|
v-if="inbound.stream.xhttp.mode === 'stream-up'"
|
||||||
v-model.trim="inbound.stream.xhttp.scStreamUpServerSecs"></a-input>
|
>
|
||||||
|
<a-input v-model.trim="inbound.stream.xhttp.scStreamUpServerSecs"></a-input>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="Padding Bytes">
|
<a-form-item label="Padding Bytes">
|
||||||
<a-input v-model.trim="inbound.stream.xhttp.xPaddingBytes"></a-input>
|
<a-input v-model.trim="inbound.stream.xhttp.xPaddingBytes"></a-input>
|
||||||
@@ -56,25 +84,34 @@
|
|||||||
</a-form-item>
|
</a-form-item>
|
||||||
<template v-if="inbound.stream.xhttp.xPaddingObfsMode">
|
<template v-if="inbound.stream.xhttp.xPaddingObfsMode">
|
||||||
<a-form-item label="Padding Key">
|
<a-form-item label="Padding Key">
|
||||||
<a-input v-model.trim="inbound.stream.xhttp.xPaddingKey"
|
<a-input
|
||||||
placeholder="x_padding"></a-input>
|
v-model.trim="inbound.stream.xhttp.xPaddingKey"
|
||||||
|
placeholder="x_padding"
|
||||||
|
></a-input>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="Padding Header">
|
<a-form-item label="Padding Header">
|
||||||
<a-input v-model.trim="inbound.stream.xhttp.xPaddingHeader"
|
<a-input
|
||||||
placeholder="X-Padding"></a-input>
|
v-model.trim="inbound.stream.xhttp.xPaddingHeader"
|
||||||
|
placeholder="X-Padding"
|
||||||
|
></a-input>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="Padding Placement">
|
<a-form-item label="Padding Placement">
|
||||||
<a-select v-model="inbound.stream.xhttp.xPaddingPlacement"
|
<a-select
|
||||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
v-model="inbound.stream.xhttp.xPaddingPlacement"
|
||||||
|
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||||
|
>
|
||||||
<a-select-option value>Default (queryInHeader)</a-select-option>
|
<a-select-option value>Default (queryInHeader)</a-select-option>
|
||||||
<a-select-option
|
<a-select-option value="queryInHeader">queryInHeader</a-select-option>
|
||||||
value="queryInHeader">queryInHeader</a-select-option>
|
|
||||||
<a-select-option value="header">header</a-select-option>
|
<a-select-option value="header">header</a-select-option>
|
||||||
|
<a-select-option value="cookie">cookie</a-select-option>
|
||||||
|
<a-select-option value="query">query</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="Padding Method">
|
<a-form-item label="Padding Method">
|
||||||
<a-select v-model="inbound.stream.xhttp.xPaddingMethod"
|
<a-select
|
||||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
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>Default (repeat-x)</a-select-option>
|
||||||
<a-select-option value="repeat-x">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-option value="tokenish">tokenish</a-select-option>
|
||||||
@@ -82,8 +119,10 @@
|
|||||||
</a-form-item>
|
</a-form-item>
|
||||||
</template>
|
</template>
|
||||||
<a-form-item label="Uplink HTTP Method">
|
<a-form-item label="Uplink HTTP Method">
|
||||||
<a-select v-model="inbound.stream.xhttp.uplinkHTTPMethod"
|
<a-select
|
||||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
v-model="inbound.stream.xhttp.uplinkHTTPMethod"
|
||||||
|
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||||
|
>
|
||||||
<a-select-option value>Default (POST)</a-select-option>
|
<a-select-option value>Default (POST)</a-select-option>
|
||||||
<a-select-option value="POST">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="PUT">PUT</a-select-option>
|
||||||
@@ -91,8 +130,10 @@
|
|||||||
</a-select>
|
</a-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="Session Placement">
|
<a-form-item label="Session Placement">
|
||||||
<a-select v-model="inbound.stream.xhttp.sessionPlacement"
|
<a-select
|
||||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
v-model="inbound.stream.xhttp.sessionPlacement"
|
||||||
|
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||||
|
>
|
||||||
<a-select-option value>Default (path)</a-select-option>
|
<a-select-option value>Default (path)</a-select-option>
|
||||||
<a-select-option value="path">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="header">header</a-select-option>
|
||||||
@@ -100,14 +141,20 @@
|
|||||||
<a-select-option value="query">query</a-select-option>
|
<a-select-option value="query">query</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="Session Key"
|
<a-form-item
|
||||||
v-if="inbound.stream.xhttp.sessionPlacement && inbound.stream.xhttp.sessionPlacement !== 'path'">
|
label="Session Key"
|
||||||
<a-input v-model.trim="inbound.stream.xhttp.sessionKey"
|
v-if="inbound.stream.xhttp.sessionPlacement && inbound.stream.xhttp.sessionPlacement !== 'path'"
|
||||||
placeholder="x_session"></a-input>
|
>
|
||||||
|
<a-input
|
||||||
|
v-model.trim="inbound.stream.xhttp.sessionKey"
|
||||||
|
placeholder="x_session"
|
||||||
|
></a-input>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="Sequence Placement">
|
<a-form-item label="Sequence Placement">
|
||||||
<a-select v-model="inbound.stream.xhttp.seqPlacement"
|
<a-select
|
||||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
v-model="inbound.stream.xhttp.seqPlacement"
|
||||||
|
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||||
|
>
|
||||||
<a-select-option value>Default (path)</a-select-option>
|
<a-select-option value>Default (path)</a-select-option>
|
||||||
<a-select-option value="path">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="header">header</a-select-option>
|
||||||
@@ -115,30 +162,48 @@
|
|||||||
<a-select-option value="query">query</a-select-option>
|
<a-select-option value="query">query</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="Sequence Key"
|
<a-form-item
|
||||||
v-if="inbound.stream.xhttp.seqPlacement && inbound.stream.xhttp.seqPlacement !== 'path'">
|
label="Sequence Key"
|
||||||
<a-input v-model.trim="inbound.stream.xhttp.seqKey"
|
v-if="inbound.stream.xhttp.seqPlacement && inbound.stream.xhttp.seqPlacement !== 'path'"
|
||||||
placeholder="x_seq"></a-input>
|
>
|
||||||
|
<a-input
|
||||||
|
v-model.trim="inbound.stream.xhttp.seqKey"
|
||||||
|
placeholder="x_seq"
|
||||||
|
></a-input>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="Uplink Data Placement"
|
<a-form-item
|
||||||
v-if="inbound.stream.xhttp.mode === 'packet-up'">
|
label="Uplink Data Placement"
|
||||||
<a-select v-model="inbound.stream.xhttp.uplinkDataPlacement"
|
v-if="inbound.stream.xhttp.mode === 'packet-up'"
|
||||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
>
|
||||||
|
<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>Default (body)</a-select-option>
|
||||||
<a-select-option value="body">body</a-select-option>
|
<a-select-option value="body">body</a-select-option>
|
||||||
<a-select-option value="header">header</a-select-option>
|
<a-select-option value="header">header</a-select-option>
|
||||||
|
<a-select-option value="cookie">cookie</a-select-option>
|
||||||
<a-select-option value="query">query</a-select-option>
|
<a-select-option value="query">query</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="Uplink Data Key"
|
<a-form-item
|
||||||
v-if="inbound.stream.xhttp.mode === 'packet-up' && inbound.stream.xhttp.uplinkDataPlacement && inbound.stream.xhttp.uplinkDataPlacement !== 'body'">
|
label="Uplink Data Key"
|
||||||
<a-input v-model.trim="inbound.stream.xhttp.uplinkDataKey"
|
v-if="inbound.stream.xhttp.mode === 'packet-up' && inbound.stream.xhttp.uplinkDataPlacement && inbound.stream.xhttp.uplinkDataPlacement !== 'body'"
|
||||||
placeholder="x_data"></a-input>
|
>
|
||||||
|
<a-input
|
||||||
|
v-model.trim="inbound.stream.xhttp.uplinkDataKey"
|
||||||
|
placeholder="x_data"
|
||||||
|
></a-input>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="Uplink Chunk Size"
|
<a-form-item
|
||||||
v-if="inbound.stream.xhttp.mode === 'packet-up' && inbound.stream.xhttp.uplinkDataPlacement && inbound.stream.xhttp.uplinkDataPlacement !== 'body'">
|
label="Uplink Chunk Size"
|
||||||
<a-input-number v-model.number="inbound.stream.xhttp.uplinkChunkSize"
|
v-if="inbound.stream.xhttp.mode === 'packet-up' && inbound.stream.xhttp.uplinkDataPlacement && inbound.stream.xhttp.uplinkDataPlacement !== 'body'"
|
||||||
:min="0" placeholder="0 (unlimited)"></a-input-number>
|
>
|
||||||
|
<a-input-number
|
||||||
|
v-model.number="inbound.stream.xhttp.uplinkChunkSize"
|
||||||
|
:min="0"
|
||||||
|
placeholder="0 (unlimited)"
|
||||||
|
></a-input-number>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="No SSE Header">
|
<a-form-item label="No SSE Header">
|
||||||
<a-switch v-model="inbound.stream.xhttp.noSSEHeader"></a-switch>
|
<a-switch v-model="inbound.stream.xhttp.noSSEHeader"></a-switch>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<!-- tls enable -->
|
<!-- tls enable -->
|
||||||
<a-form v-if="inbound.canEnableTls()" :colon="false"
|
<a-form v-if="inbound.canEnableTls()" :colon="false"
|
||||||
:label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
:label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||||
|
<template v-if="inbound.protocol !== Protocols.HYSTERIA">
|
||||||
<a-divider :style="{ margin: '3px 0' }"></a-divider>
|
<a-divider :style="{ margin: '3px 0' }"></a-divider>
|
||||||
<a-form-item label='{{ i18n "security" }}'>
|
<a-form-item label='{{ i18n "security" }}'>
|
||||||
<a-radio-group v-model="inbound.stream.security" button-style="solid">
|
<a-radio-group v-model="inbound.stream.security" button-style="solid">
|
||||||
@@ -11,6 +12,8 @@
|
|||||||
<a-radio-button value="tls">TLS</a-radio-button>
|
<a-radio-button value="tls">TLS</a-radio-button>
|
||||||
</a-radio-group>
|
</a-radio-group>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
</template>
|
||||||
|
<template v-else><a-divider style="margin:0;">TLS</a-divider></template>
|
||||||
|
|
||||||
<!-- tls settings -->
|
<!-- tls settings -->
|
||||||
<template v-if="inbound.stream.isTls">
|
<template v-if="inbound.stream.isTls">
|
||||||
@@ -58,9 +61,6 @@
|
|||||||
]]</a-select-option>
|
]]</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="Allow Insecure">
|
|
||||||
<a-switch v-model="inbound.stream.tls.settings.allowInsecure"></a-switch>
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item label="Reject Unknown SNI">
|
<a-form-item label="Reject Unknown SNI">
|
||||||
<a-switch v-model="inbound.stream.tls.rejectUnknownSni"></a-switch>
|
<a-switch v-model="inbound.stream.tls.rejectUnknownSni"></a-switch>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|||||||
+785
-310
File diff suppressed because it is too large
Load Diff
+222
-6
@@ -2,11 +2,25 @@
|
|||||||
{{ template "page/head_end" .}}
|
{{ template "page/head_end" .}}
|
||||||
|
|
||||||
{{ template "page/body_start" .}}
|
{{ template "page/body_start" .}}
|
||||||
|
<style>
|
||||||
|
body.dark .custom-geo-section code.custom-geo-ext-code {
|
||||||
|
color: var(--dark-color-text-primary, rgba(255, 255, 255, 0.85));
|
||||||
|
background: var(--dark-color-surface-200, #222d42);
|
||||||
|
border: 1px solid var(--dark-color-stroke, #2c3950);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
html[data-theme="ultra-dark"] body.dark .custom-geo-section code.custom-geo-ext-code {
|
||||||
|
color: var(--dark-color-text-primary, rgba(255, 255, 255, 0.88));
|
||||||
|
background: var(--dark-color-surface-700, #111929);
|
||||||
|
border-color: var(--dark-color-stroke, #2c3950);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
<a-layout id="app" v-cloak :class="themeSwitcher.currentTheme + ' index-page'">
|
<a-layout id="app" v-cloak :class="themeSwitcher.currentTheme + ' index-page'">
|
||||||
<a-sidebar></a-sidebar>
|
<a-sidebar></a-sidebar>
|
||||||
<a-layout id="content-layout">
|
<a-layout id="content-layout">
|
||||||
<a-layout-content>
|
<a-layout-content>
|
||||||
<a-spin :spinning="loadingStates.spinning" :delay="200" :tip="loadingTip">
|
<a-spin :spinning="loadingStates.spinning" :delay="200" :tip="loadingTip" size="large">
|
||||||
<transition name="list" appear>
|
<transition name="list" appear>
|
||||||
<a-alert type="error" v-if="showAlert && loadingStates.fetched" class="mb-10"
|
<a-alert type="error" v-if="showAlert && loadingStates.fetched" class="mb-10"
|
||||||
message='{{ i18n "secAlertTitle" }}' color="red" description='{{ i18n "secAlertSsl" }}' show-icon closable>
|
message='{{ i18n "secAlertTitle" }}' color="red" description='{{ i18n "secAlertSsl" }}' show-icon closable>
|
||||||
@@ -15,9 +29,7 @@
|
|||||||
<transition name="list" appear>
|
<transition name="list" appear>
|
||||||
<template>
|
<template>
|
||||||
<a-row v-if="!loadingStates.fetched">
|
<a-row v-if="!loadingStates.fetched">
|
||||||
<a-card class="card-placeholder text-center">
|
<div :style="{ minHeight: 'calc(100vh - 120px)' }"></div>
|
||||||
<a-spin tip='{{ i18n "loading" }}'></a-spin>
|
|
||||||
</a-card>
|
|
||||||
</a-row>
|
</a-row>
|
||||||
<a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-else>
|
<a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-else>
|
||||||
<a-col>
|
<a-col>
|
||||||
@@ -107,7 +119,7 @@
|
|||||||
</a-row>
|
</a-row>
|
||||||
</span>
|
</span>
|
||||||
<template slot="content">
|
<template slot="content">
|
||||||
<span class="max-w-400" v-for="line in status.xray.errorMsg.split('\n')">[[ line ]]</span>
|
<span class="max-w-400" v-for="line in (status.xray.errorMsg || '').split('\n')">[[ line ]]</span>
|
||||||
</template>
|
</template>
|
||||||
<a-badge :text="status.xray.stateMsg" :color="status.xray.color"
|
<a-badge :text="status.xray.stateMsg" :color="status.xray.color"
|
||||||
:class="status.xray.color === 'red' ? 'xray-error-animation' : ''" />
|
:class="status.xray.color === 'red' ? 'xray-error-animation' : ''" />
|
||||||
@@ -115,7 +127,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<a-space v-if="app.ipLimitEnable" direction="horizontal" @click="openXrayLogs()" class="jc-center">
|
<a-space v-if="ipLimitEnable" direction="horizontal" @click="openXrayLogs()" class="jc-center">
|
||||||
<a-icon type="bars"></a-icon>
|
<a-icon type="bars"></a-icon>
|
||||||
<span v-if="!isMobile">{{ i18n "pages.index.logs" }}</span>
|
<span v-if="!isMobile">{{ i18n "pages.index.logs" }}</span>
|
||||||
</a-space>
|
</a-space>
|
||||||
@@ -330,8 +342,65 @@
|
|||||||
<div class="mt-5 d-flex justify-end"><a-button @click="updateGeofile('')">{{ i18n
|
<div class="mt-5 d-flex justify-end"><a-button @click="updateGeofile('')">{{ i18n
|
||||||
"pages.index.geofilesUpdateAll" }}</a-button></div>
|
"pages.index.geofilesUpdateAll" }}</a-button></div>
|
||||||
</a-collapse-panel>
|
</a-collapse-panel>
|
||||||
|
<a-collapse-panel key="3" header='{{ i18n "pages.index.customGeoTitle" }}'>
|
||||||
|
<div class="custom-geo-section">
|
||||||
|
<a-alert type="info" show-icon class="mb-10"
|
||||||
|
message='{{ i18n "pages.index.customGeoRoutingHint" }}'></a-alert>
|
||||||
|
<div class="mb-10">
|
||||||
|
<a-button type="primary" icon="plus" @click="openCustomGeoModal(null)" :loading="customGeoLoading">
|
||||||
|
{{ i18n "pages.index.customGeoAdd" }}
|
||||||
|
</a-button>
|
||||||
|
<a-button class="ml-8" icon="reload" @click="updateAllCustomGeo" :loading="customGeoUpdatingAll">{{ i18n
|
||||||
|
"pages.index.geofilesUpdateAll" }}</a-button>
|
||||||
|
</div>
|
||||||
|
<a-table :columns="customGeoColumns" :data-source="customGeoList" :pagination="false" :row-key="r => r.id"
|
||||||
|
:loading="customGeoLoading" size="small" :scroll="{ x: 520 }">
|
||||||
|
<template slot="extDat" slot-scope="text, record">
|
||||||
|
<code class="custom-geo-ext-code">[[ customGeoExtDisplay(record) ]]</code>
|
||||||
|
</template>
|
||||||
|
<template slot="lastUpdatedAt" slot-scope="text, record">
|
||||||
|
<span v-if="record.lastUpdatedAt">[[ customGeoFormatTime(record.lastUpdatedAt) ]]</span>
|
||||||
|
<span v-else>—</span>
|
||||||
|
</template>
|
||||||
|
<template slot="action" slot-scope="text, record">
|
||||||
|
<a-space size="small">
|
||||||
|
<a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
|
||||||
|
<template slot="title">{{ i18n "pages.index.customGeoEdit" }}</template>
|
||||||
|
<a-button type="link" size="small" icon="edit" @click="openCustomGeoModal(record)"></a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
<a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
|
||||||
|
<template slot="title">{{ i18n "pages.index.customGeoDownload" }}</template>
|
||||||
|
<a-button type="link" size="small" icon="reload" @click="downloadCustomGeo(record.id)" :loading="customGeoActionId === record.id"></a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
<a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
|
||||||
|
<template slot="title">{{ i18n "pages.index.customGeoDelete" }}</template>
|
||||||
|
<a-button type="link" size="small" icon="delete" @click="confirmDeleteCustomGeo(record)"></a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</div>
|
||||||
|
</a-collapse-panel>
|
||||||
</a-collapse>
|
</a-collapse>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
|
<a-modal v-model="customGeoModal.visible" :title="customGeoModal.editId ? '{{ i18n "pages.index.customGeoModalEdit" }}' : '{{ i18n "pages.index.customGeoModalAdd" }}'"
|
||||||
|
:confirm-loading="customGeoModal.saving" @ok="submitCustomGeo" :ok-text="'{{ i18n "pages.index.customGeoModalSave" }}'" :cancel-text="'{{ i18n "close" }}'"
|
||||||
|
:class="themeSwitcher.currentTheme">
|
||||||
|
<a-form layout="vertical">
|
||||||
|
<a-form-item label='{{ i18n "pages.index.customGeoType" }}'>
|
||||||
|
<a-select v-model="customGeoModal.form.type" :disabled="!!customGeoModal.editId" :dropdown-class-name="themeSwitcher.currentTheme">
|
||||||
|
<a-select-option value="geosite">geosite</a-select-option>
|
||||||
|
<a-select-option value="geoip">geoip</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label='{{ i18n "pages.index.customGeoAlias" }}'>
|
||||||
|
<a-input v-model.trim="customGeoModal.form.alias" :disabled="!!customGeoModal.editId" placeholder='{{ i18n "pages.index.customGeoAliasPlaceholder" }}'></a-input>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label='{{ i18n "pages.index.customGeoUrl" }}'>
|
||||||
|
<a-input v-model.trim="customGeoModal.form.url" placeholder="https://"></a-input>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
<a-modal id="log-modal" v-model="logModal.visible" :closable="true" @cancel="() => logModal.visible = false"
|
<a-modal id="log-modal" v-model="logModal.visible" :closable="true" @cancel="() => logModal.visible = false"
|
||||||
:class="themeSwitcher.currentTheme" width="800px" footer="">
|
:class="themeSwitcher.currentTheme" width="800px" footer="">
|
||||||
<template slot="title">
|
<template slot="title">
|
||||||
@@ -872,6 +941,12 @@
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const customGeoColumns = [
|
||||||
|
{ title: '{{ i18n "pages.index.customGeoExtColumn" }}', key: 'extDat', scopedSlots: { customRender: 'extDat' }, ellipsis: true },
|
||||||
|
{ title: '{{ i18n "pages.index.customGeoLastUpdated" }}', key: 'lastUpdatedAt', scopedSlots: { customRender: 'lastUpdatedAt' }, width: 160 },
|
||||||
|
{ title: '{{ i18n "pages.index.customGeoActions" }}', key: 'action', scopedSlots: { customRender: 'action' }, width: 120, fixed: 'right' },
|
||||||
|
];
|
||||||
|
|
||||||
const app = new Vue({
|
const app = new Vue({
|
||||||
delimiters: ['[[', ']]'],
|
delimiters: ['[[', ']]'],
|
||||||
el: '#app',
|
el: '#app',
|
||||||
@@ -895,6 +970,25 @@
|
|||||||
showAlert: false,
|
showAlert: false,
|
||||||
showIp: false,
|
showIp: false,
|
||||||
ipLimitEnable: false,
|
ipLimitEnable: false,
|
||||||
|
customGeoColumns,
|
||||||
|
customGeoList: [],
|
||||||
|
customGeoLoading: false,
|
||||||
|
customGeoUpdatingAll: false,
|
||||||
|
customGeoActionId: null,
|
||||||
|
customGeoModal: {
|
||||||
|
visible: false,
|
||||||
|
editId: null,
|
||||||
|
saving: false,
|
||||||
|
form: {
|
||||||
|
type: 'geosite',
|
||||||
|
alias: '',
|
||||||
|
url: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
customGeoValidation: {
|
||||||
|
alias: '{{ i18n "pages.index.customGeoValidationAlias" }}',
|
||||||
|
url: '{{ i18n "pages.index.customGeoValidationUrl" }}',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
loading(spinning, tip = '{{ i18n "loading"}}') {
|
loading(spinning, tip = '{{ i18n "loading"}}') {
|
||||||
@@ -963,6 +1057,128 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
versionModal.show(msg.obj);
|
versionModal.show(msg.obj);
|
||||||
|
this.loadCustomGeo();
|
||||||
|
},
|
||||||
|
customGeoFormatTime(ts) {
|
||||||
|
if (!ts) return '';
|
||||||
|
return typeof moment !== 'undefined' ? moment(ts * 1000).format('YYYY-MM-DD HH:mm') : String(ts);
|
||||||
|
},
|
||||||
|
customGeoExtDisplay(record) {
|
||||||
|
const fn = record.type === 'geoip'
|
||||||
|
? `geoip_${record.alias}.dat`
|
||||||
|
: `geosite_${record.alias}.dat`;
|
||||||
|
return `ext:${fn}:tag`;
|
||||||
|
},
|
||||||
|
async loadCustomGeo() {
|
||||||
|
this.customGeoLoading = true;
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.get('/panel/api/custom-geo/list');
|
||||||
|
if (msg.success && Array.isArray(msg.obj)) {
|
||||||
|
this.customGeoList = msg.obj;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.customGeoLoading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openCustomGeoModal(record) {
|
||||||
|
if (record) {
|
||||||
|
this.customGeoModal.editId = record.id;
|
||||||
|
this.customGeoModal.form = {
|
||||||
|
type: record.type,
|
||||||
|
alias: record.alias,
|
||||||
|
url: record.url,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
this.customGeoModal.editId = null;
|
||||||
|
this.customGeoModal.form = {
|
||||||
|
type: 'geosite',
|
||||||
|
alias: '',
|
||||||
|
url: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
this.customGeoModal.visible = true;
|
||||||
|
},
|
||||||
|
validateCustomGeoForm() {
|
||||||
|
const f = this.customGeoModal.form;
|
||||||
|
const re = /^[a-z0-9_-]+$/;
|
||||||
|
if (!re.test(f.alias || '')) {
|
||||||
|
this.$message.error(this.customGeoValidation.alias);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const u = (f.url || '').trim();
|
||||||
|
if (!/^https?:\/\//i.test(u)) {
|
||||||
|
this.$message.error(this.customGeoValidation.url);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = new URL(u);
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||||
|
this.$message.error(this.customGeoValidation.url);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.$message.error(this.customGeoValidation.url);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
async submitCustomGeo() {
|
||||||
|
if (!this.validateCustomGeoForm()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const f = this.customGeoModal.form;
|
||||||
|
this.customGeoModal.saving = true;
|
||||||
|
try {
|
||||||
|
let msg;
|
||||||
|
if (this.customGeoModal.editId) {
|
||||||
|
msg = await HttpUtil.post(`/panel/api/custom-geo/update/${this.customGeoModal.editId}`, f);
|
||||||
|
} else {
|
||||||
|
msg = await HttpUtil.post('/panel/api/custom-geo/add', f);
|
||||||
|
}
|
||||||
|
if (msg && msg.success) {
|
||||||
|
this.customGeoModal.visible = false;
|
||||||
|
await this.loadCustomGeo();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.customGeoModal.saving = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmDeleteCustomGeo(record) {
|
||||||
|
this.$confirm({
|
||||||
|
title: '{{ i18n "pages.index.customGeoDelete" }}',
|
||||||
|
content: '{{ i18n "pages.index.customGeoDeleteConfirm" }}',
|
||||||
|
okText: '{{ i18n "confirm"}}',
|
||||||
|
cancelText: '{{ i18n "cancel"}}',
|
||||||
|
class: themeSwitcher.currentTheme,
|
||||||
|
onOk: async () => {
|
||||||
|
const msg = await HttpUtil.post(`/panel/api/custom-geo/delete/${record.id}`);
|
||||||
|
if (msg.success) {
|
||||||
|
await this.loadCustomGeo();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async downloadCustomGeo(id) {
|
||||||
|
this.customGeoActionId = id;
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.post(`/panel/api/custom-geo/download/${id}`);
|
||||||
|
if (msg.success) {
|
||||||
|
await this.loadCustomGeo();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.customGeoActionId = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async updateAllCustomGeo() {
|
||||||
|
this.customGeoUpdatingAll = true;
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.post('/panel/api/custom-geo/update-all');
|
||||||
|
if (msg.success || (msg.obj && Array.isArray(msg.obj.succeeded) && msg.obj.succeeded.length > 0)) {
|
||||||
|
await this.loadCustomGeo();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.customGeoUpdatingAll = false;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
switchV2rayVersion(version) {
|
switchV2rayVersion(version) {
|
||||||
this.$confirm({
|
this.$confirm({
|
||||||
|
|||||||
@@ -1,52 +1,75 @@
|
|||||||
{{define "modals/clientsBulkModal"}}
|
{{define "modals/clientsBulkModal"}}
|
||||||
<a-modal id="client-bulk-modal" v-model="clientsBulkModal.visible" :title="clientsBulkModal.title"
|
<a-modal id="client-bulk-modal" v-model="clientsBulkModal.visible"
|
||||||
@ok="clientsBulkModal.ok" :confirm-loading="clientsBulkModal.confirmLoading" :closable="true" :mask-closable="false"
|
:title="clientsBulkModal.title"
|
||||||
:ok-text="clientsBulkModal.okText" cancel-text='{{ i18n "close" }}' :class="themeSwitcher.currentTheme">
|
@ok="clientsBulkModal.ok" :confirm-loading="clientsBulkModal.confirmLoading"
|
||||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
: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-form-item label='{{ i18n "pages.client.method" }}'>
|
||||||
<a-select v-model="clientsBulkModal.emailMethod" buttonStyle="solid"
|
<a-select v-model="clientsBulkModal.emailMethod" buttonStyle="solid"
|
||||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||||
<a-select-option :value="0">Random</a-select-option>
|
<a-select-option :value="0">Random</a-select-option>
|
||||||
<a-select-option :value="1">Random+Prefix</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="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-option :value="4">Prefix+Num+Postfix</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label='{{ i18n "pages.client.first" }}' v-if="clientsBulkModal.emailMethod>1">
|
<a-form-item label='{{ i18n "pages.client.first" }}'
|
||||||
<a-input-number v-model.number="clientsBulkModal.firstNum" :min="1"></a-input-number>
|
v-if="clientsBulkModal.emailMethod>1">
|
||||||
|
<a-input-number v-model.number="clientsBulkModal.firstNum"
|
||||||
|
:min="1"></a-input-number>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label='{{ i18n "pages.client.last" }}' v-if="clientsBulkModal.emailMethod>1">
|
<a-form-item label='{{ i18n "pages.client.last" }}'
|
||||||
<a-input-number v-model.number="clientsBulkModal.lastNum" :min="clientsBulkModal.firstNum"></a-input-number>
|
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>
|
||||||
<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-input v-model.trim="clientsBulkModal.emailPrefix"></a-input>
|
||||||
</a-form-item>
|
</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-input v-model.trim="clientsBulkModal.emailPostfix"></a-input>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label='{{ i18n "pages.client.clientCount" }}' v-if="clientsBulkModal.emailMethod < 2">
|
<a-form-item label='{{ i18n "pages.client.clientCount" }}'
|
||||||
<a-input-number v-model.number="clientsBulkModal.quantity" :min="1" :max="100"></a-input-number>
|
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>
|
||||||
<a-form-item label='{{ i18n "security" }}' v-if="inbound.protocol === Protocols.VMESS">
|
<a-form-item label='{{ i18n "security" }}'
|
||||||
<a-select v-model="clientsBulkModal.security" :dropdown-class-name="themeSwitcher.currentTheme">
|
v-if="inbound.protocol === Protocols.VMESS">
|
||||||
<a-select-option v-for="key in USERS_SECURITY" :value="key">[[ key ]]</a-select-option>
|
<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-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label='Flow' v-if="clientsBulkModal.inbound.canEnableTlsFlow()">
|
<a-form-item label='Flow'
|
||||||
<a-select v-model="clientsBulkModal.flow" :dropdown-class-name="themeSwitcher.currentTheme">
|
v-if="clientsBulkModal.inbound.canEnableTlsFlow()">
|
||||||
<a-select-option value="" selected>{{ i18n "none" }}</a-select-option>
|
<a-select v-model="clientsBulkModal.flow"
|
||||||
<a-select-option v-for="key in TLS_FLOW_CONTROL" :value="key">[[ key ]]</a-select-option>
|
: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-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item v-if="app.subSettings?.enable">
|
<a-form-item v-if="app.subSettings?.enable">
|
||||||
<template slot="label">
|
<template slot="label">
|
||||||
<a-tooltip>
|
<a-tooltip>
|
||||||
<template slot="title">
|
<template slot="title">
|
||||||
<span>{{ i18n "pages.inbounds.subscriptionDesc" }}</span>
|
<span>{{ i18n "pages.inbounds.subscriptionDesc"
|
||||||
|
}}</span>
|
||||||
</template>
|
</template>
|
||||||
Subscription
|
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>
|
</a-tooltip>
|
||||||
</template>
|
</template>
|
||||||
<a-input v-model.trim="clientsBulkModal.subId"></a-input>
|
<a-input v-model.trim="clientsBulkModal.subId"></a-input>
|
||||||
@@ -61,7 +84,8 @@
|
|||||||
<a-icon type="question-circle"></a-icon>
|
<a-icon type="question-circle"></a-icon>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
</template>
|
</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>
|
||||||
<a-form-item v-if="app.ipLimitEnable">
|
<a-form-item v-if="app.ipLimitEnable">
|
||||||
<template slot="label">
|
<template slot="label">
|
||||||
@@ -73,7 +97,8 @@
|
|||||||
<a-icon type="question-circle"></a-icon>
|
<a-icon type="question-circle"></a-icon>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
</template>
|
</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>
|
||||||
<a-form-item>
|
<a-form-item>
|
||||||
<template slot="label">
|
<template slot="label">
|
||||||
@@ -85,29 +110,38 @@
|
|||||||
<a-icon type="question-circle"></a-icon>
|
<a-icon type="question-circle"></a-icon>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
</template>
|
</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>
|
||||||
<a-form-item label='{{ i18n "pages.client.delayedStart" }}'>
|
<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>
|
||||||
<a-form-item label='{{ i18n "pages.client.expireDays" }}' v-if="clientsBulkModal.delayedStart">
|
<a-form-item label='{{ i18n "pages.client.expireDays" }}'
|
||||||
<a-input-number v-model.number="delayedExpireDays" :min="0"></a-input-number>
|
v-if="clientsBulkModal.delayedStart">
|
||||||
|
<a-input-number v-model.number="delayedExpireDays"
|
||||||
|
:min="0"></a-input-number>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item v-else>
|
<a-form-item v-else>
|
||||||
<template slot="label">
|
<template slot="label">
|
||||||
<a-tooltip>
|
<a-tooltip>
|
||||||
<template slot="title">
|
<template slot="title">
|
||||||
<span>{{ i18n "pages.inbounds.leaveBlankToNeverExpire" }}</span>
|
<span>{{ i18n "pages.inbounds.leaveBlankToNeverExpire"
|
||||||
|
}}</span>
|
||||||
</template>
|
</template>
|
||||||
{{ i18n "pages.inbounds.expireDate" }}
|
{{ i18n "pages.inbounds.expireDate" }}
|
||||||
<a-icon type="question-circle"></a-icon>
|
<a-icon type="question-circle"></a-icon>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
</template>
|
</template>
|
||||||
<a-date-picker v-if="datepicker == 'gregorian'" :show-time="{ format: 'HH:mm:ss' }"
|
<a-date-picker v-if="datepicker == 'gregorian'"
|
||||||
format="YYYY-MM-DD HH:mm:ss" :dropdown-class-name="themeSwitcher.currentTheme"
|
: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>
|
v-model="clientsBulkModal.expiryTime"></a-date-picker>
|
||||||
<a-persian-datepicker v-else placeholder='{{ i18n "pages.settings.datepickerPlaceholder" }}'
|
<a-persian-datepicker v-else
|
||||||
value="clientsBulkModal.expiryTime" v-model="clientsBulkModal.expiryTime">
|
placeholder='{{ i18n "pages.settings.datepickerPlaceholder" }}'
|
||||||
|
value="clientsBulkModal.expiryTime"
|
||||||
|
v-model="clientsBulkModal.expiryTime">
|
||||||
</a-persian-datepicker>
|
</a-persian-datepicker>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item v-if="clientsBulkModal.expiryTime != 0">
|
<a-form-item v-if="clientsBulkModal.expiryTime != 0">
|
||||||
@@ -120,7 +154,8 @@
|
|||||||
<a-icon type="question-circle"></a-icon>
|
<a-icon type="question-circle"></a-icon>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
</template>
|
</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-item>
|
||||||
</a-form>
|
</a-form>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
@@ -204,7 +239,7 @@
|
|||||||
this.security = "auto";
|
this.security = "auto";
|
||||||
this.flow = "";
|
this.flow = "";
|
||||||
this.dbInbound = new DBInbound(dbInbound);
|
this.dbInbound = new DBInbound(dbInbound);
|
||||||
this.inbound = dbInbound.toInbound();
|
this.inbound = Inbound.fromJson(dbInbound.toInbound().toJson());
|
||||||
this.delayedStart = false;
|
this.delayedStart = false;
|
||||||
this.reset = 0;
|
this.reset = 0;
|
||||||
},
|
},
|
||||||
@@ -214,6 +249,7 @@
|
|||||||
case Protocols.VLESS: return new Inbound.VLESSSettings.VLESS();
|
case Protocols.VLESS: return new Inbound.VLESSSettings.VLESS();
|
||||||
case Protocols.TROJAN: return new Inbound.TrojanSettings.Trojan();
|
case Protocols.TROJAN: return new Inbound.TrojanSettings.Trojan();
|
||||||
case Protocols.SHADOWSOCKS: return new Inbound.ShadowsocksSettings.Shadowsocks(clientsBulkModal.inbound.settings.shadowsockses[0].method);
|
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;
|
default: return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
{{define "modals/clientsModal"}}
|
{{define "modals/clientsModal"}}
|
||||||
<a-modal id="client-modal" v-model="clientModal.visible" :title="clientModal.title" @ok="clientModal.ok"
|
<a-modal id="client-modal" v-model="clientModal.visible"
|
||||||
:confirm-loading="clientModal.confirmLoading" :closable="true" :mask-closable="false"
|
:title="clientModal.title" @ok="clientModal.ok"
|
||||||
|
:confirm-loading="clientModal.confirmLoading" :closable="true"
|
||||||
|
:mask-closable="false"
|
||||||
:class="themeSwitcher.currentTheme"
|
:class="themeSwitcher.currentTheme"
|
||||||
:ok-text="clientModal.okText" cancel-text='{{ i18n "close" }}'>
|
:ok-text="clientModal.okText" cancel-text='{{ i18n "close" }}'>
|
||||||
<template v-if="isEdit">
|
<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>
|
||||||
{{template "form/client"}}
|
{{template "form/client"}}
|
||||||
</a-modal>
|
</a-modal>
|
||||||
@@ -37,7 +41,7 @@
|
|||||||
this.okText = okText;
|
this.okText = okText;
|
||||||
this.isEdit = isEdit;
|
this.isEdit = isEdit;
|
||||||
this.dbInbound = new DBInbound(dbInbound);
|
this.dbInbound = new DBInbound(dbInbound);
|
||||||
this.inbound = dbInbound.toInbound();
|
this.inbound = Inbound.fromJson(dbInbound.toInbound().toJson());
|
||||||
this.clients = this.inbound.clients;
|
this.clients = this.inbound.clients;
|
||||||
this.index = index === null ? this.clients.length : index;
|
this.index = index === null ? this.clients.length : index;
|
||||||
this.delayedStart = false;
|
this.delayedStart = false;
|
||||||
@@ -56,6 +60,7 @@
|
|||||||
switch (protocol) {
|
switch (protocol) {
|
||||||
case Protocols.TROJAN: return client.password;
|
case Protocols.TROJAN: return client.password;
|
||||||
case Protocols.SHADOWSOCKS: return client.email;
|
case Protocols.SHADOWSOCKS: return client.email;
|
||||||
|
case Protocols.HYSTERIA: return client.auth;
|
||||||
default: return client.id;
|
default: return client.id;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -65,6 +70,7 @@
|
|||||||
case Protocols.VLESS: return clients.push(new Inbound.VLESSSettings.VLESS());
|
case Protocols.VLESS: return clients.push(new Inbound.VLESSSettings.VLESS());
|
||||||
case Protocols.TROJAN: return clients.push(new Inbound.TrojanSettings.Trojan());
|
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.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;
|
default: return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -98,9 +104,9 @@
|
|||||||
return app.datepicker;
|
return app.datepicker;
|
||||||
},
|
},
|
||||||
get isTrafficExhausted() {
|
get isTrafficExhausted() {
|
||||||
if (!clientStats) return false
|
if (!this.clientStats) return false
|
||||||
if (clientStats.total <= 0) return false
|
if (this.clientStats.total <= 0) return false
|
||||||
if (clientStats.up + clientStats.down < clientStats.total) return false
|
if (this.clientStats.up + this.clientStats.down < this.clientStats.total) return false
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
get isExpiry() {
|
get isExpiry() {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{{define "modals/inboundInfoModal"}}
|
{{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-row>
|
||||||
<a-col :xs="24" :md="12">
|
<a-col :xs="24" :md="12">
|
||||||
<table>
|
<table>
|
||||||
@@ -66,26 +67,13 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="inbound.isKcp">
|
|
||||||
<tr>
|
|
||||||
<td>kcp {{ i18n "encryption" }}</td>
|
|
||||||
<td>
|
|
||||||
<a-tag>[[ inbound.kcpType ]]</a-tag>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>kcp {{ i18n "password" }}</td>
|
|
||||||
<td>
|
|
||||||
<a-tag>[[ inbound.kcpSeed ]]</a-tag>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
<template v-if="inbound.isGrpc">
|
<template v-if="inbound.isGrpc">
|
||||||
<tr>
|
<tr>
|
||||||
<td>grpc serviceName</td>
|
<td>grpc serviceName</td>
|
||||||
<td>
|
<td>
|
||||||
<a-tooltip :title="[[ inbound.serviceName ]]">
|
<a-tooltip :title="[[ inbound.serviceName ]]">
|
||||||
<a-tag class="info-large-tag">[[ inbound.serviceName ]]</a-tag>
|
<a-tag class="info-large-tag">[[ inbound.serviceName
|
||||||
|
]]</a-tag>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
<tr>
|
<tr>
|
||||||
<td>grpc multiMode</td>
|
<td>grpc multiMode</td>
|
||||||
@@ -99,21 +87,27 @@
|
|||||||
</a-col>
|
</a-col>
|
||||||
<template v-if="dbInbound.hasLink()">
|
<template v-if="dbInbound.hasLink()">
|
||||||
{{ i18n "security" }}
|
{{ i18n "security" }}
|
||||||
<a-tag :color="inbound.stream.security == 'none' ? 'red' : 'green'">[[ inbound.stream.security ]]</a-tag>
|
<a-tag :color="inbound.stream.security == 'none' ? 'red' : 'green'">[[
|
||||||
|
inbound.stream.security ]]</a-tag>
|
||||||
<br />
|
<br />
|
||||||
<td>Authentication</td>
|
<td>Authentication</td>
|
||||||
<a-tag v-if="inbound.settings.selectedAuth" color="green">[[ inbound.settings.selectedAuth ? inbound.settings.selectedAuth : '' ]]</a-tag>
|
<a-tag v-if="inbound.settings.selectedAuth" color="green">[[
|
||||||
|
inbound.settings.selectedAuth ? inbound.settings.selectedAuth : ''
|
||||||
|
]]</a-tag>
|
||||||
<a-tag v-else color="red">{{ i18n "none" }}</a-tag>
|
<a-tag v-else color="red">{{ i18n "none" }}</a-tag>
|
||||||
<br />
|
<br />
|
||||||
{{ i18n "encryption" }}
|
{{ i18n "encryption" }}
|
||||||
<a-tag class="info-large-tag" :color="inbound.settings.encryption ? 'green' : 'red'">[[ inbound.settings.encryption ? inbound.settings.encryption : '' ]]</a-tag>
|
<a-tag class="info-large-tag" :color="inbound.settings.encryption ? 'green' : 'red'">[[
|
||||||
|
inbound.settings.encryption ? inbound.settings.encryption : ''
|
||||||
|
]]</a-tag>
|
||||||
<a-tooltip title='{{ i18n "copy" }}'>
|
<a-tooltip title='{{ i18n "copy" }}'>
|
||||||
<a-button size="small" icon="snippets" @click="copy(inbound.settings.encryption)"></a-button>
|
<a-button size="small" icon="snippets" @click="copy(inbound.settings.encryption)"></a-button>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
<br />
|
<br />
|
||||||
<template v-if="inbound.stream.security != 'none'">
|
<template v-if="inbound.stream.security != 'none'">
|
||||||
{{ i18n "domainName" }}
|
{{ i18n "domainName" }}
|
||||||
<a-tag v-if="inbound.serverName" color="green">[[ inbound.serverName ? inbound.serverName : '' ]]</a-tag>
|
<a-tag v-if="inbound.serverName" color="green">[[ inbound.serverName
|
||||||
|
? inbound.serverName : '' ]]</a-tag>
|
||||||
<a-tag v-else color="orange">{{ i18n "none" }}</a-tag>
|
<a-tag v-else color="orange">{{ i18n "none" }}</a-tag>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
@@ -128,7 +122,8 @@
|
|||||||
<td>{{ i18n "password" }}</td>
|
<td>{{ i18n "password" }}</td>
|
||||||
<td>
|
<td>
|
||||||
<a-tooltip :title="[[ inbound.settings.password ]]">
|
<a-tooltip :title="[[ inbound.settings.password ]]">
|
||||||
<a-tag class="info-large-tag">[[ inbound.settings.password ]]</a-tag>
|
<a-tag class="info-large-tag">[[ inbound.settings.password
|
||||||
|
]]</a-tag>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -145,7 +140,8 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>{{ i18n "pages.inbounds.email" }}</td>
|
<td>{{ i18n "pages.inbounds.email" }}</td>
|
||||||
<td v-if="infoModal.clientSettings.email">
|
<td v-if="infoModal.clientSettings.email">
|
||||||
<a-tag color="green">[[ infoModal.clientSettings.email ]]</a-tag>
|
<a-tag color="green">[[ infoModal.clientSettings.email
|
||||||
|
]]</a-tag>
|
||||||
</td>
|
</td>
|
||||||
<td v-else>
|
<td v-else>
|
||||||
<a-tag color="red">{{ i18n "none" }}</a-tag>
|
<a-tag color="red">{{ i18n "none" }}</a-tag>
|
||||||
@@ -176,30 +172,39 @@
|
|||||||
<td>{{ i18n "password" }}</td>
|
<td>{{ i18n "password" }}</td>
|
||||||
<td>
|
<td>
|
||||||
<a-tooltip :title="[[ infoModal.clientSettings.password ]]">
|
<a-tooltip :title="[[ infoModal.clientSettings.password ]]">
|
||||||
<a-tag class="info-large-tag">[[ infoModal.clientSettings.password ]]</a-tag>
|
<a-tag class="info-large-tag">[[
|
||||||
|
infoModal.clientSettings.password ]]</a-tag>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ i18n "status" }}</td>
|
<td>{{ i18n "status" }}</td>
|
||||||
<td>
|
<td>
|
||||||
<a-tag v-if="isDepleted" color="red">{{ i18n "depleted" }}</a-tag>
|
<a-tag v-if="isDepleted" color="red">{{ i18n "depleted"
|
||||||
<a-tag v-else-if="isEnable" color="green">{{ i18n "enabled" }}</a-tag>
|
}}</a-tag>
|
||||||
|
<a-tag v-else-if="isEnable" color="green">{{ i18n "enabled"
|
||||||
|
}}</a-tag>
|
||||||
<a-tag v-else>{{ i18n "disabled" }}</a-tag>
|
<a-tag v-else>{{ i18n "disabled" }}</a-tag>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="infoModal.clientStats">
|
<tr v-if="infoModal.clientStats">
|
||||||
<td>{{ i18n "usage" }}</td>
|
<td>{{ i18n "usage" }}</td>
|
||||||
<td>
|
<td>
|
||||||
<a-tag color="green">[[ SizeFormatter.sizeFormat(infoModal.clientStats.up + infoModal.clientStats.down) ]]</a-tag>
|
<a-tag color="green">[[
|
||||||
<a-tag>↑ [[ SizeFormatter.sizeFormat(infoModal.clientStats.up) ]] / [[ SizeFormatter.sizeFormat(infoModal.clientStats.down) ]] ↓</a-tag>
|
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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ i18n "pages.inbounds.createdAt" }}</td>
|
<td>{{ i18n "pages.inbounds.createdAt" }}</td>
|
||||||
<td>
|
<td>
|
||||||
<template v-if="infoModal.clientSettings && infoModal.clientSettings.created_at">
|
<template v-if="infoModal.clientSettings && infoModal.clientSettings.created_at">
|
||||||
<a-tag>[[ IntlUtil.formatDate(infoModal.clientSettings.created_at) ]]</a-tag>
|
<a-tag>[[
|
||||||
|
IntlUtil.formatDate(infoModal.clientSettings.created_at)
|
||||||
|
]]</a-tag>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<a-tag>-</a-tag>
|
<a-tag>-</a-tag>
|
||||||
@@ -210,7 +215,9 @@
|
|||||||
<td>{{ i18n "pages.inbounds.updatedAt" }}</td>
|
<td>{{ i18n "pages.inbounds.updatedAt" }}</td>
|
||||||
<td>
|
<td>
|
||||||
<template v-if="infoModal.clientSettings && infoModal.clientSettings.updated_at">
|
<template v-if="infoModal.clientSettings && infoModal.clientSettings.updated_at">
|
||||||
<a-tag>[[ IntlUtil.formatDate(infoModal.clientSettings.updated_at) ]]</a-tag>
|
<a-tag>[[
|
||||||
|
IntlUtil.formatDate(infoModal.clientSettings.updated_at)
|
||||||
|
]]</a-tag>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<a-tag>-</a-tag>
|
<a-tag>-</a-tag>
|
||||||
@@ -220,14 +227,17 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>{{ i18n "lastOnline" }}</td>
|
<td>{{ i18n "lastOnline" }}</td>
|
||||||
<td>
|
<td>
|
||||||
<a-tag>[[ app.formatLastOnline(infoModal.clientSettings && infoModal.clientSettings.email ? infoModal.clientSettings.email : '') ]]</a-tag>
|
<a-tag>[[ app.formatLastOnline(infoModal.clientSettings &&
|
||||||
|
infoModal.clientSettings.email ?
|
||||||
|
infoModal.clientSettings.email : '') ]]</a-tag>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="infoModal.clientSettings.comment">
|
<tr v-if="infoModal.clientSettings.comment">
|
||||||
<td>{{ i18n "comment" }}</td>
|
<td>{{ i18n "comment" }}</td>
|
||||||
<td>
|
<td>
|
||||||
<a-tooltip :title="[[ infoModal.clientSettings.comment ]]">
|
<a-tooltip :title="[[ infoModal.clientSettings.comment ]]">
|
||||||
<a-tag class="info-large-tag">[[ infoModal.clientSettings.comment ]]</a-tag>
|
<a-tag class="info-large-tag">[[
|
||||||
|
infoModal.clientSettings.comment ]]</a-tag>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -240,14 +250,25 @@
|
|||||||
<tr v-if="app.ipLimitEnable && infoModal.clientSettings.limitIp > 0">
|
<tr v-if="app.ipLimitEnable && infoModal.clientSettings.limitIp > 0">
|
||||||
<td>{{ i18n "pages.inbounds.IPLimitlog" }}</td>
|
<td>{{ i18n "pages.inbounds.IPLimitlog" }}</td>
|
||||||
<td>
|
<td>
|
||||||
<a-tag>[[ infoModal.clientIps ]]</a-tag>
|
<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-icon type="sync" :spin="refreshing" @click="refreshIPs" :style="{ margin: '0 5px' }"></a-icon>
|
||||||
<a-tooltip :title="[[ dbInbound.address ]]">
|
<a-tooltip>
|
||||||
<template slot="title">
|
<template slot="title">
|
||||||
<span>{{ i18n "pages.inbounds.IPLimitlogclear" }}</span>
|
<span>{{ i18n "pages.inbounds.IPLimitlogclear" }}</span>
|
||||||
</template>
|
</template>
|
||||||
<a-icon type="delete" @click="clearClientIps"></a-icon>
|
<a-icon type="delete" @click="clearClientIps"></a-icon>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -259,27 +280,39 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<a-tag v-if="infoModal.clientStats && infoModal.clientSettings.totalGB > 0" :color="statsColor(infoModal.clientStats)"> [[ getRemStats() ]] </a-tag>
|
<a-tag v-if="infoModal.clientStats && infoModal.clientSettings.totalGB > 0"
|
||||||
|
:color="statsColor(infoModal.clientStats)"> [[ getRemStats()
|
||||||
|
]] </a-tag>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<a-tag v-if="infoModal.clientSettings.totalGB > 0" :color="statsColor(infoModal.clientStats)"> [[ SizeFormatter.sizeFormat(infoModal.clientSettings.totalGB) ]] </a-tag>
|
<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">
|
<a-tag v-else color="purple" class="infinite-tag">
|
||||||
<svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor">
|
<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>
|
<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>
|
</svg>
|
||||||
</a-tag>
|
</a-tag>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<template v-if="infoModal.clientSettings.expiryTime > 0">
|
<template v-if="infoModal.clientSettings.expiryTime > 0">
|
||||||
<a-tag :color="ColorUtils.usageColor(new Date().getTime(), app.expireDiff, infoModal.clientSettings.expiryTime)">
|
<a-tag
|
||||||
[[ IntlUtil.formatDate(infoModal.clientSettings.expiryTime) ]]
|
:color="ColorUtils.usageColor(new Date().getTime(), app.expireDiff, infoModal.clientSettings.expiryTime)">
|
||||||
|
[[ IntlUtil.formatDate(infoModal.clientSettings.expiryTime)
|
||||||
|
]]
|
||||||
</a-tag>
|
</a-tag>
|
||||||
</template>
|
</template>
|
||||||
<a-tag v-else-if="infoModal.clientSettings.expiryTime < 0" color="green">[[ infoModal.clientSettings.expiryTime / -86400000 ]] {{ i18n "pages.client.days" }}
|
<a-tag v-else-if="infoModal.clientSettings.expiryTime < 0" color="green">[[
|
||||||
|
infoModal.clientSettings.expiryTime /
|
||||||
|
-86400000 ]] {{ i18n "pages.client.days" }}
|
||||||
</a-tag>
|
</a-tag>
|
||||||
<a-tag v-else color="purple" class="infinite-tag">
|
<a-tag v-else color="purple" class="infinite-tag">
|
||||||
<svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor">
|
<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>
|
<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>
|
</svg>
|
||||||
</a-tag>
|
</a-tag>
|
||||||
</td>
|
</td>
|
||||||
@@ -294,7 +327,8 @@
|
|||||||
<a-button size="small" icon="snippets" @click="copy(infoModal.subLink)"></a-button>
|
<a-button size="small" icon="snippets" @click="copy(infoModal.subLink)"></a-button>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
</tr-info-title>
|
</tr-info-title>
|
||||||
<a :href="[[ infoModal.subLink ]]" target="_blank">[[ infoModal.subLink ]]</a>
|
<a :href="[[ infoModal.subLink ]]" target="_blank">[[
|
||||||
|
infoModal.subLink ]]</a>
|
||||||
</tr-info-row>
|
</tr-info-row>
|
||||||
<tr-info-row class="tr-info-row" v-if="app.subSettings.subJsonEnable">
|
<tr-info-row class="tr-info-row" v-if="app.subSettings.subJsonEnable">
|
||||||
<tr-info-title class="tr-info-title">
|
<tr-info-title class="tr-info-title">
|
||||||
@@ -303,7 +337,8 @@
|
|||||||
<a-button size="small" icon="snippets" @click="copy(infoModal.subJsonLink)"></a-button>
|
<a-button size="small" icon="snippets" @click="copy(infoModal.subJsonLink)"></a-button>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
</tr-info-title>
|
</tr-info-title>
|
||||||
<a :href="[[ infoModal.subJsonLink ]]" target="_blank">[[ infoModal.subJsonLink ]]</a>
|
<a :href="[[ infoModal.subJsonLink ]]" target="_blank">[[
|
||||||
|
infoModal.subJsonLink ]]</a>
|
||||||
</tr-info-row>
|
</tr-info-row>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="app.tgBotEnable && infoModal.clientSettings.tgId">
|
<template v-if="app.tgBotEnable && infoModal.clientSettings.tgId">
|
||||||
@@ -321,7 +356,8 @@
|
|||||||
<a-divider>URL</a-divider>
|
<a-divider>URL</a-divider>
|
||||||
<tr-info-row v-for="(link,index) in infoModal.links" class="tr-info-row">
|
<tr-info-row v-for="(link,index) in infoModal.links" class="tr-info-row">
|
||||||
<tr-info-title class="tr-info-title">
|
<tr-info-title class="tr-info-title">
|
||||||
<a-tag class="tr-info-tag" color="green">[[ link.remark ]]</a-tag>
|
<a-tag class="tr-info-tag" color="green">[[ link.remark
|
||||||
|
]]</a-tag>
|
||||||
<a-tooltip title='{{ i18n "copy" }}'>
|
<a-tooltip title='{{ i18n "copy" }}'>
|
||||||
<a-button :style="{ minWidth: '24px' }" size="small" icon="snippets" @click="copy(link.link)"></a-button>
|
<a-button :style="{ minWidth: '24px' }" size="small" icon="snippets" @click="copy(link.link)"></a-button>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
@@ -335,7 +371,8 @@
|
|||||||
<a-divider>URL</a-divider>
|
<a-divider>URL</a-divider>
|
||||||
<tr-info-row v-for="(link,index) in infoModal.links" class="tr-info-row">
|
<tr-info-row v-for="(link,index) in infoModal.links" class="tr-info-row">
|
||||||
<tr-info-title class="tr-info-title">
|
<tr-info-title class="tr-info-title">
|
||||||
<a-tag class="tr-info-tag" color="green">[[ link.remark ]]</a-tag>
|
<a-tag class="tr-info-tag" color="green">[[ link.remark
|
||||||
|
]]</a-tag>
|
||||||
<a-tooltip title='{{ i18n "copy" }}'>
|
<a-tooltip title='{{ i18n "copy" }}'>
|
||||||
<a-button :style="{ minWidth: '24px' }" size="small" icon="snippets" @click="copy(link.link)"></a-button>
|
<a-button :style="{ minWidth: '24px' }" size="small" icon="snippets" @click="copy(link.link)"></a-button>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
@@ -361,7 +398,8 @@
|
|||||||
<a-tag color="green">[[ inbound.settings.network ]]</a-tag>
|
<a-tag color="green">[[ inbound.settings.network ]]</a-tag>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<a-tag color="green">[[ inbound.settings.followRedirect ]]</a-tag>
|
<a-tag color="green">[[ inbound.settings.followRedirect
|
||||||
|
]]</a-tag>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -464,17 +502,30 @@
|
|||||||
<tr-info-title class="tr-info-title">
|
<tr-info-title class="tr-info-title">
|
||||||
<a-tag color="blue">Config</a-tag>
|
<a-tag color="blue">Config</a-tag>
|
||||||
<a-tooltip title='{{ i18n "copy" }}'>
|
<a-tooltip title='{{ i18n "copy" }}'>
|
||||||
<a-button :style="{ minWidth: '24px' }" size="small" icon="snippets" @click="copy(infoModal.links[index])"></a-button>
|
<a-button :style="{ minWidth: '24px' }" size="small" icon="snippets"
|
||||||
|
@click="copy(infoModal.links[index])"></a-button>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
<a-tooltip title='{{ i18n "download" }}'>
|
<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-button :style="{ minWidth: '24px' }" size="small" icon="download"
|
||||||
|
@click="FileManager.downloadTextFile(infoModal.links[index], `peer-${index + 1}.conf`)"></a-button>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
</tr-info-title>
|
</tr-info-title>
|
||||||
<div v-html="infoModal.links[index].replaceAll(`\n`,`<br />`)" :style="{ borderRadius: '1rem', padding: '0.5rem' }" class="client-table-odd-row">
|
<div v-html="infoModal.links[index].replaceAll(`\n`,`<br />`)"
|
||||||
|
:style="{ borderRadius: '1rem', padding: '0.5rem' }" class="client-table-odd-row">
|
||||||
</div>
|
</div>
|
||||||
|
<a-divider orientation="center">Link</a-divider>
|
||||||
|
<tr-info-title class="tr-info-title">
|
||||||
|
<a-tag color="green">Link</a-tag>
|
||||||
|
<a-tooltip title='{{ i18n "copy" }}'>
|
||||||
|
<a-button :style="{ minWidth: '24px' }" size="small" icon="snippets"
|
||||||
|
@click="copy(infoModal.wireguardLinks[index])"></a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
</tr-info-title>
|
||||||
|
<code :style="{ display: 'block', whiteSpace: 'normal', wordBreak: 'break-all' }">[[ infoModal.wireguardLinks[index] ]]</code>
|
||||||
</tr-info-row>
|
</tr-info-row>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
</template>
|
||||||
</table>
|
</table>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
@@ -482,12 +533,73 @@
|
|||||||
<script>
|
<script>
|
||||||
function refreshIPs(email) {
|
function refreshIPs(email) {
|
||||||
return HttpUtil.post(`/panel/api/inbounds/clientIps/${email}`).then((msg) => {
|
return HttpUtil.post(`/panel/api/inbounds/clientIps/${email}`).then((msg) => {
|
||||||
if (msg.success) {
|
if (!msg.success) {
|
||||||
try {
|
return { text: 'No IP Record', array: [] };
|
||||||
return JSON.parse(msg.obj).join(', ');
|
|
||||||
} catch (e) {
|
|
||||||
return msg.obj;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatIpRecord = (record) => {
|
||||||
|
if (record == null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (typeof record === 'string' || typeof record === 'number') {
|
||||||
|
return String(record);
|
||||||
|
}
|
||||||
|
const ip = record.ip || record.IP || '';
|
||||||
|
const timestamp = record.timestamp || record.Timestamp || 0;
|
||||||
|
if (!ip) {
|
||||||
|
return String(record);
|
||||||
|
}
|
||||||
|
if (!timestamp) {
|
||||||
|
return String(ip);
|
||||||
|
}
|
||||||
|
const date = new Date(Number(timestamp) * 1000);
|
||||||
|
const timeStr = date
|
||||||
|
.toLocaleString('en-GB', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
})
|
||||||
|
.replace(',', '');
|
||||||
|
return `${ip} (${timeStr})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
let ips = msg.obj;
|
||||||
|
// If msg.obj is a string, try to parse it
|
||||||
|
if (typeof ips === 'string') {
|
||||||
|
try {
|
||||||
|
ips = JSON.parse(ips);
|
||||||
|
} catch (e) {
|
||||||
|
return { text: String(ips), array: [String(ips)] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize single object response to array
|
||||||
|
if (ips && !Array.isArray(ips) && typeof ips === 'object') {
|
||||||
|
ips = [ips];
|
||||||
|
}
|
||||||
|
|
||||||
|
// New format or object array
|
||||||
|
if (Array.isArray(ips) && ips.length > 0 && typeof ips[0] === 'object') {
|
||||||
|
const result = ips.map((item) => formatIpRecord(item)).filter(Boolean);
|
||||||
|
return { text: result.join(' | '), array: result };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Old format - simple array of IPs
|
||||||
|
if (Array.isArray(ips) && ips.length > 0) {
|
||||||
|
const result = ips.map((ip) => String(ip));
|
||||||
|
return { text: result.join(', '), array: result };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for any other format
|
||||||
|
return { text: String(ips), array: [String(ips)] };
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
return { text: 'Error loading IPs', array: [] };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -501,11 +613,13 @@
|
|||||||
upStats: 0,
|
upStats: 0,
|
||||||
downStats: 0,
|
downStats: 0,
|
||||||
links: [],
|
links: [],
|
||||||
|
wireguardLinks: [],
|
||||||
index: null,
|
index: null,
|
||||||
isExpired: false,
|
isExpired: false,
|
||||||
subLink: '',
|
subLink: '',
|
||||||
subJsonLink: '',
|
subJsonLink: '',
|
||||||
clientIps: '',
|
clientIps: '',
|
||||||
|
clientIpsArray: [],
|
||||||
show(dbInbound, index) {
|
show(dbInbound, index) {
|
||||||
this.index = index;
|
this.index = index;
|
||||||
this.inbound = dbInbound.toInbound();
|
this.inbound = dbInbound.toInbound();
|
||||||
@@ -523,15 +637,18 @@
|
|||||||
].includes(this.inbound.protocol)
|
].includes(this.inbound.protocol)
|
||||||
) {
|
) {
|
||||||
if (app.ipLimitEnable && this.clientSettings.limitIp) {
|
if (app.ipLimitEnable && this.clientSettings.limitIp) {
|
||||||
refreshIPs(this.clientStats.email).then((ips) => {
|
refreshIPs(this.clientStats.email).then((result) => {
|
||||||
this.clientIps = ips;
|
this.clientIps = result.text;
|
||||||
|
this.clientIpsArray = result.array;
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (this.inbound.protocol == Protocols.WIREGUARD) {
|
if (this.inbound.protocol == Protocols.WIREGUARD) {
|
||||||
this.links = this.inbound.genInboundLinks(dbInbound.remark).split('\r\n')
|
this.links = this.inbound.genWireguardConfigs(dbInbound.remark).split('\r\n')
|
||||||
|
this.wireguardLinks = this.inbound.genWireguardLinks(dbInbound.remark).split('\r\n')
|
||||||
} else {
|
} else {
|
||||||
this.links = this.inbound.genAllLinks(this.dbInbound.remark, app.remarkModel, this.clientSettings);
|
this.links = this.inbound.genAllLinks(this.dbInbound.remark, app.remarkModel, this.clientSettings);
|
||||||
|
this.wireguardLinks = [];
|
||||||
}
|
}
|
||||||
if (this.clientSettings) {
|
if (this.clientSettings) {
|
||||||
if (this.clientSettings.subId) {
|
if (this.clientSettings.subId) {
|
||||||
@@ -595,6 +712,35 @@
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
formatIpInfo(ipInfo) {
|
||||||
|
if (ipInfo == null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (typeof ipInfo === 'string' || typeof ipInfo === 'number') {
|
||||||
|
return String(ipInfo);
|
||||||
|
}
|
||||||
|
const ip = ipInfo.ip || ipInfo.IP || '';
|
||||||
|
const timestamp = ipInfo.timestamp || ipInfo.Timestamp || 0;
|
||||||
|
if (!ip) {
|
||||||
|
return String(ipInfo);
|
||||||
|
}
|
||||||
|
if (!timestamp) {
|
||||||
|
return String(ip);
|
||||||
|
}
|
||||||
|
const date = new Date(Number(timestamp) * 1000);
|
||||||
|
const timeStr = date
|
||||||
|
.toLocaleString('en-GB', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
})
|
||||||
|
.replace(',', '');
|
||||||
|
return `${ip} (${timeStr})`;
|
||||||
|
},
|
||||||
copy(content) {
|
copy(content) {
|
||||||
ClipboardManager
|
ClipboardManager
|
||||||
.copyText(content)
|
.copyText(content)
|
||||||
@@ -612,8 +758,9 @@
|
|||||||
refreshIPs() {
|
refreshIPs() {
|
||||||
this.refreshing = true;
|
this.refreshing = true;
|
||||||
refreshIPs(this.infoModal.clientStats.email)
|
refreshIPs(this.infoModal.clientStats.email)
|
||||||
.then((ips) => {
|
.then((result) => {
|
||||||
this.infoModal.clientIps = ips;
|
this.infoModal.clientIps = result.text;
|
||||||
|
this.infoModal.clientIpsArray = result.array;
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.refreshing = false;
|
this.refreshing = false;
|
||||||
@@ -626,6 +773,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.infoModal.clientIps = 'No IP Record';
|
this.infoModal.clientIps = 'No IP Record';
|
||||||
|
this.infoModal.clientIpsArray = [];
|
||||||
})
|
})
|
||||||
.catch(() => { });
|
.catch(() => { });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,10 +5,9 @@
|
|||||||
{{template "form/inbound"}}
|
{{template "form/inbound"}}
|
||||||
</a-modal>
|
</a-modal>
|
||||||
<script>
|
<script>
|
||||||
|
|
||||||
// Make inModal globally available to ensure it works with any base path
|
// Make inModal globally available to ensure it works with any base path
|
||||||
const inModal = window.inModal = {
|
const inModal = (window.inModal = {
|
||||||
title: '',
|
title: "",
|
||||||
visible: false,
|
visible: false,
|
||||||
confirmLoading: false,
|
confirmLoading: false,
|
||||||
okText: '{{ i18n "sure" }}',
|
okText: '{{ i18n "sure" }}',
|
||||||
@@ -19,7 +18,14 @@
|
|||||||
ok() {
|
ok() {
|
||||||
ObjectUtil.execute(inModal.confirm, inModal.inbound, inModal.dbInbound);
|
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.title = title;
|
||||||
this.okText = okText;
|
this.okText = okText;
|
||||||
if (inbound) {
|
if (inbound) {
|
||||||
@@ -30,7 +36,11 @@
|
|||||||
// Always ensure testseed is initialized for VLESS protocol (even if vision flow is not set yet)
|
// Always ensure testseed is initialized for VLESS protocol (even if vision flow is not set yet)
|
||||||
// This ensures Vue reactivity works properly
|
// This ensures Vue reactivity works properly
|
||||||
if (this.inbound.protocol === Protocols.VLESS && this.inbound.settings) {
|
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
|
// Create a new array to ensure Vue reactivity
|
||||||
this.inbound.settings.testseed = [900, 500, 900, 256].slice();
|
this.inbound.settings.testseed = [900, 500, 900, 256].slice();
|
||||||
}
|
}
|
||||||
@@ -56,7 +66,10 @@
|
|||||||
// Use inModal.inbound explicitly to ensure correct context
|
// Use inModal.inbound explicitly to ensure correct context
|
||||||
if (!inModal.inbound || !inModal.inbound.settings) return;
|
if (!inModal.inbound || !inModal.inbound.settings) return;
|
||||||
// Ensure testseed is initialized
|
// 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];
|
inModal.inbound.settings.testseed = [900, 500, 900, 256];
|
||||||
}
|
}
|
||||||
// Ensure array has enough elements
|
// Ensure array has enough elements
|
||||||
@@ -70,26 +83,35 @@
|
|||||||
// Use inModal.inbound explicitly to ensure correct context
|
// Use inModal.inbound explicitly to ensure correct context
|
||||||
if (!inModal.inbound || !inModal.inbound.settings) return;
|
if (!inModal.inbound || !inModal.inbound.settings) return;
|
||||||
// Ensure testseed is initialized
|
// 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();
|
inModal.inbound.settings.testseed = [900, 500, 900, 256].slice();
|
||||||
}
|
}
|
||||||
// Create new array with random values
|
// 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() {
|
resetTestseed() {
|
||||||
// Use inModal.inbound explicitly to ensure correct context
|
// Use inModal.inbound explicitly to ensure correct context
|
||||||
if (!inModal.inbound || !inModal.inbound.settings) return;
|
if (!inModal.inbound || !inModal.inbound.settings) return;
|
||||||
// Reset testseed to default values
|
// Reset testseed to default values
|
||||||
inModal.inbound.settings.testseed = [900, 500, 900, 256].slice();
|
inModal.inbound.settings.testseed = [900, 500, 900, 256].slice();
|
||||||
}
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
// Store Vue instance globally to ensure methods are always accessible
|
// Store Vue instance globally to ensure methods are always accessible
|
||||||
let inboundModalVueInstance = null;
|
let inboundModalVueInstance = null;
|
||||||
|
|
||||||
inboundModalVueInstance = new Vue({
|
inboundModalVueInstance = new Vue({
|
||||||
delimiters: ['[[', ']]'],
|
delimiters: ["[[", "]]"],
|
||||||
el: '#inbound-modal',
|
el: "#inbound-modal",
|
||||||
data: {
|
data: {
|
||||||
inModal: inModal,
|
inModal: inModal,
|
||||||
delayedStart: false,
|
delayedStart: false,
|
||||||
@@ -103,13 +125,19 @@
|
|||||||
return inModal.isEdit;
|
return inModal.isEdit;
|
||||||
},
|
},
|
||||||
get client() {
|
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() {
|
get datepicker() {
|
||||||
return app.datepicker;
|
return app.datepicker;
|
||||||
},
|
},
|
||||||
get delayedExpireDays() {
|
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) {
|
set delayedExpireDays(days) {
|
||||||
this.client.expiryTime = -86400000 * days;
|
this.client.expiryTime = -86400000 * days;
|
||||||
@@ -119,72 +147,103 @@
|
|||||||
},
|
},
|
||||||
set externalProxy(value) {
|
set externalProxy(value) {
|
||||||
if (value) {
|
if (value) {
|
||||||
inModal.inbound.stream.externalProxy = [{
|
inModal.inbound.stream.externalProxy = [
|
||||||
|
{
|
||||||
forceTls: "same",
|
forceTls: "same",
|
||||||
dest: window.location.hostname,
|
dest: window.location.hostname,
|
||||||
port: inModal.inbound.port,
|
port: inModal.inbound.port,
|
||||||
remark: ""
|
remark: "",
|
||||||
}];
|
},
|
||||||
|
];
|
||||||
} else {
|
} else {
|
||||||
inModal.inbound.stream.externalProxy = [];
|
inModal.inbound.stream.externalProxy = [];
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
'inModal.inbound.stream.security'(newVal, oldVal) {
|
"inModal.inbound.stream.security"(newVal, oldVal) {
|
||||||
// Clear flow when security changes from reality/tls to none
|
// Clear flow when security changes from reality/tls to none
|
||||||
if (inModal.inbound.protocol == Protocols.VLESS && !inModal.inbound.canEnableTlsFlow()) {
|
if (
|
||||||
inModal.inbound.settings.vlesses.forEach(client => {
|
inModal.inbound.protocol == Protocols.VLESS &&
|
||||||
|
!inModal.inbound.canEnableTlsFlow()
|
||||||
|
) {
|
||||||
|
inModal.inbound.settings.vlesses.forEach((client) => {
|
||||||
client.flow = "";
|
client.flow = "";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Ensure testseed is always initialized when vision flow is enabled
|
// Ensure testseed is always initialized when vision flow is enabled
|
||||||
'inModal.inbound.settings.vlesses': {
|
"inModal.inbound.settings.vlesses": {
|
||||||
handler() {
|
handler() {
|
||||||
if (inModal.inbound.protocol === Protocols.VLESS && inModal.inbound.settings && inModal.inbound.settings.vlesses) {
|
if (
|
||||||
const hasVisionFlow = inModal.inbound.settings.vlesses.some(c => c.flow === 'xtls-rprx-vision' || c.flow === 'xtls-rprx-vision-udp443');
|
inModal.inbound.protocol === Protocols.VLESS &&
|
||||||
if (hasVisionFlow && (!inModal.inbound.settings.testseed || !Array.isArray(inModal.inbound.settings.testseed) || inModal.inbound.settings.testseed.length < 4)) {
|
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];
|
inModal.inbound.settings.testseed = [900, 500, 900, 256];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
deep: true
|
deep: true,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
streamNetworkChange() {
|
streamNetworkChange() {
|
||||||
if (!inModal.inbound.canEnableTls()) {
|
if (!inModal.inbound.canEnableTls()) {
|
||||||
this.inModal.inbound.stream.security = 'none';
|
this.inModal.inbound.stream.security = "none";
|
||||||
}
|
}
|
||||||
if (!inModal.inbound.canEnableReality()) {
|
if (!inModal.inbound.canEnableReality()) {
|
||||||
this.inModal.inbound.reality = false;
|
this.inModal.inbound.reality = false;
|
||||||
}
|
}
|
||||||
if (this.inModal.inbound.protocol == Protocols.VLESS && !inModal.inbound.canEnableTlsFlow()) {
|
if (
|
||||||
this.inModal.inbound.settings.vlesses.forEach(client => {
|
this.inModal.inbound.protocol == Protocols.VLESS &&
|
||||||
|
!inModal.inbound.canEnableTlsFlow()
|
||||||
|
) {
|
||||||
|
this.inModal.inbound.settings.vlesses.forEach((client) => {
|
||||||
client.flow = "";
|
client.flow = "";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (inModal.inbound.stream.network != "kcp") {
|
||||||
|
inModal.inbound.stream.finalmask.udp = [];
|
||||||
|
}
|
||||||
},
|
},
|
||||||
SSMethodChange() {
|
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.isSSMultiUser) {
|
||||||
if (this.inModal.inbound.settings.shadowsockses.length == 0) {
|
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) {
|
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;
|
client.method = this.inModal.inbound.settings.method;
|
||||||
})
|
});
|
||||||
} else {
|
} else {
|
||||||
this.inModal.inbound.settings.shadowsockses.forEach(client => {
|
this.inModal.inbound.settings.shadowsockses.forEach((client) => {
|
||||||
client.method = "";
|
client.method = "";
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
this.inModal.inbound.settings.shadowsockses.forEach(client => {
|
this.inModal.inbound.settings.shadowsockses.forEach((client) => {
|
||||||
client.password = RandomUtil.randomShadowsocksPassword(this.inModal.inbound.settings.method)
|
client.password = RandomUtil.randomShadowsocksPassword(
|
||||||
})
|
this.inModal.inbound.settings.method,
|
||||||
|
);
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
if (this.inModal.inbound.settings.shadowsockses.length > 0) {
|
if (this.inModal.inbound.settings.shadowsockses.length > 0) {
|
||||||
this.inModal.inbound.settings.shadowsockses = [];
|
this.inModal.inbound.settings.shadowsockses = [];
|
||||||
@@ -197,7 +256,7 @@
|
|||||||
},
|
},
|
||||||
async getNewX25519Cert() {
|
async getNewX25519Cert() {
|
||||||
inModal.loading(true);
|
inModal.loading(true);
|
||||||
const msg = await HttpUtil.get('/panel/api/server/getNewX25519Cert');
|
const msg = await HttpUtil.get("/panel/api/server/getNewX25519Cert");
|
||||||
inModal.loading(false);
|
inModal.loading(false);
|
||||||
if (!msg.success) {
|
if (!msg.success) {
|
||||||
return;
|
return;
|
||||||
@@ -206,12 +265,12 @@
|
|||||||
inModal.inbound.stream.reality.settings.publicKey = msg.obj.publicKey;
|
inModal.inbound.stream.reality.settings.publicKey = msg.obj.publicKey;
|
||||||
},
|
},
|
||||||
clearX25519Cert() {
|
clearX25519Cert() {
|
||||||
this.inbound.stream.reality.privateKey = '';
|
this.inbound.stream.reality.privateKey = "";
|
||||||
this.inbound.stream.reality.settings.publicKey = '';
|
this.inbound.stream.reality.settings.publicKey = "";
|
||||||
},
|
},
|
||||||
async getNewmldsa65() {
|
async getNewmldsa65() {
|
||||||
inModal.loading(true);
|
inModal.loading(true);
|
||||||
const msg = await HttpUtil.get('/panel/api/server/getNewmldsa65');
|
const msg = await HttpUtil.get("/panel/api/server/getNewmldsa65");
|
||||||
inModal.loading(false);
|
inModal.loading(false);
|
||||||
if (!msg.success) {
|
if (!msg.success) {
|
||||||
return;
|
return;
|
||||||
@@ -220,11 +279,11 @@
|
|||||||
inModal.inbound.stream.reality.settings.mldsa65Verify = msg.obj.verify;
|
inModal.inbound.stream.reality.settings.mldsa65Verify = msg.obj.verify;
|
||||||
},
|
},
|
||||||
clearMldsa65() {
|
clearMldsa65() {
|
||||||
this.inbound.stream.reality.mldsa65Seed = '';
|
this.inbound.stream.reality.mldsa65Seed = "";
|
||||||
this.inbound.stream.reality.settings.mldsa65Verify = '';
|
this.inbound.stream.reality.settings.mldsa65Verify = "";
|
||||||
},
|
},
|
||||||
randomizeRealityTarget() {
|
randomizeRealityTarget() {
|
||||||
if (typeof getRandomRealityTarget !== 'undefined') {
|
if (typeof getRandomRealityTarget !== "undefined") {
|
||||||
const randomTarget = getRandomRealityTarget();
|
const randomTarget = getRandomRealityTarget();
|
||||||
this.inbound.stream.reality.target = randomTarget.target;
|
this.inbound.stream.reality.target = randomTarget.target;
|
||||||
this.inbound.stream.reality.serverNames = randomTarget.sni;
|
this.inbound.stream.reality.serverNames = randomTarget.sni;
|
||||||
@@ -232,21 +291,30 @@
|
|||||||
},
|
},
|
||||||
async getNewEchCert() {
|
async getNewEchCert() {
|
||||||
inModal.loading(true);
|
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);
|
inModal.loading(false);
|
||||||
if (!msg.success) {
|
if (!msg.success) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
inModal.inbound.stream.tls.echServerKeys = msg.obj.echServerKeys;
|
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() {
|
clearEchCert() {
|
||||||
this.inbound.stream.tls.echServerKeys = '';
|
this.inbound.stream.tls.echServerKeys = "";
|
||||||
this.inbound.stream.tls.settings.echConfigList = '';
|
this.inbound.stream.tls.settings.echConfigList = "";
|
||||||
},
|
},
|
||||||
async getNewVlessEnc() {
|
async getNewVlessEnc() {
|
||||||
|
const selected = inModal.inbound.settings.selectedAuth;
|
||||||
|
if (!selected) {
|
||||||
|
this.clearVlessEnc();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
inModal.loading(true);
|
inModal.loading(true);
|
||||||
const msg = await HttpUtil.get('/panel/api/server/getNewVlessEnc');
|
const msg = await HttpUtil.get("/panel/api/server/getNewVlessEnc");
|
||||||
inModal.loading(false);
|
inModal.loading(false);
|
||||||
|
|
||||||
if (!msg.success) {
|
if (!msg.success) {
|
||||||
@@ -254,8 +322,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const auths = msg.obj.auths || [];
|
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) {
|
if (!block) {
|
||||||
console.error("No auth block for", selected);
|
console.error("No auth block for", selected);
|
||||||
@@ -266,15 +333,18 @@
|
|||||||
inModal.inbound.settings.encryption = block.encryption;
|
inModal.inbound.settings.encryption = block.encryption;
|
||||||
},
|
},
|
||||||
clearVlessEnc() {
|
clearVlessEnc() {
|
||||||
this.inbound.settings.decryption = 'none';
|
this.inbound.settings.decryption = "none";
|
||||||
this.inbound.settings.encryption = 'none';
|
this.inbound.settings.encryption = "none";
|
||||||
this.inbound.settings.selectedAuth = undefined;
|
this.inbound.settings.selectedAuth = undefined;
|
||||||
},
|
},
|
||||||
// Vision Seed methods - must be in Vue methods for proper binding
|
// Vision Seed methods - must be in Vue methods for proper binding
|
||||||
updateTestseed(index, value) {
|
updateTestseed(index, value) {
|
||||||
// Ensure testseed is initialized
|
// Ensure testseed is initialized
|
||||||
if (!this.inbound.settings.testseed || !Array.isArray(this.inbound.settings.testseed)) {
|
if (
|
||||||
this.$set(this.inbound.settings, 'testseed', [900, 500, 900, 256]);
|
!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
|
// Ensure array has enough elements
|
||||||
while (this.inbound.settings.testseed.length <= index) {
|
while (this.inbound.settings.testseed.length <= index) {
|
||||||
@@ -285,15 +355,19 @@
|
|||||||
},
|
},
|
||||||
setRandomTestseed() {
|
setRandomTestseed() {
|
||||||
// Create new array with random values and use Vue.set for reactivity
|
// 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)];
|
const newSeed = [
|
||||||
this.$set(this.inbound.settings, 'testseed', 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() {
|
resetTestseed() {
|
||||||
// Reset testseed to default values using Vue.set for reactivity
|
// 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>
|
</script>
|
||||||
{{end}}
|
{{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}}
|
||||||
@@ -203,6 +203,7 @@
|
|||||||
}
|
}
|
||||||
if (app.templateSettings.reverse.portals) this.outboundTags.push(...app.templateSettings.reverse.portals.map(b => b.tag));
|
if (app.templateSettings.reverse.portals) this.outboundTags.push(...app.templateSettings.reverse.portals.map(b => b.tag));
|
||||||
}
|
}
|
||||||
|
this.balancerTags = [""];
|
||||||
if (app.templateSettings.routing && app.templateSettings.routing.balancers) {
|
if (app.templateSettings.routing && app.templateSettings.routing.balancers) {
|
||||||
this.balancerTags = ["", ...app.templateSettings.routing.balancers.filter((o) => !ObjectUtil.isEmpty(o.tag)).map(obj => obj.tag)];
|
this.balancerTags = ["", ...app.templateSettings.routing.balancers.filter((o) => !ObjectUtil.isEmpty(o.tag)).map(obj => obj.tag)];
|
||||||
}
|
}
|
||||||
@@ -219,14 +220,14 @@
|
|||||||
rule = {};
|
rule = {};
|
||||||
newRule = {};
|
newRule = {};
|
||||||
rule.type = "field";
|
rule.type = "field";
|
||||||
rule.domain = value.domain.length > 0 ? value.domain.split(',') : [];
|
rule.domain = value.domain.length > 0 ? value.domain.split(',').map(s => s.trim()) : [];
|
||||||
rule.ip = value.ip.length > 0 ? value.ip.split(',') : [];
|
rule.ip = value.ip.length > 0 ? value.ip.split(',').map(s => s.trim()) : [];
|
||||||
rule.port = value.port;
|
rule.port = value.port;
|
||||||
rule.sourcePort = value.sourcePort;
|
rule.sourcePort = value.sourcePort;
|
||||||
rule.vlessRoute = value.vlessRoute;
|
rule.vlessRoute = value.vlessRoute;
|
||||||
rule.network = value.network;
|
rule.network = value.network;
|
||||||
rule.sourceIP = value.sourceIP.length > 0 ? value.sourceIP.split(',') : [];
|
rule.sourceIP = value.sourceIP.length > 0 ? value.sourceIP.split(',').map(s => s.trim()) : [];
|
||||||
rule.user = value.user.length > 0 ? value.user.split(',') : [];
|
rule.user = value.user.length > 0 ? value.user.split(',').map(s => s.trim()) : [];
|
||||||
rule.inboundTag = value.inboundTag;
|
rule.inboundTag = value.inboundTag;
|
||||||
rule.protocol = value.protocol;
|
rule.protocol = value.protocol;
|
||||||
rule.attrs = Object.fromEntries(value.attrs);
|
rule.attrs = Object.fromEntries(value.attrs);
|
||||||
|
|||||||
+16
-42
@@ -6,7 +6,7 @@
|
|||||||
<a-sidebar></a-sidebar>
|
<a-sidebar></a-sidebar>
|
||||||
<a-layout id="content-layout">
|
<a-layout id="content-layout">
|
||||||
<a-layout-content>
|
<a-layout-content>
|
||||||
<a-spin :spinning="loadingStates.spinning" :delay="500" tip='{{ i18n "loading"}}'>
|
<a-spin :spinning="loadingStates.spinning" :delay="500" tip='{{ i18n "loading"}}' size="large">
|
||||||
<transition name="list" appear>
|
<transition name="list" appear>
|
||||||
<a-alert type="error" v-if="confAlerts.length>0 && loadingStates.fetched" :style="{ marginBottom: '10px' }"
|
<a-alert type="error" v-if="confAlerts.length>0 && loadingStates.fetched" :style="{ marginBottom: '10px' }"
|
||||||
message='{{ i18n "secAlertTitle" }}' color="red" show-icon closable>
|
message='{{ i18n "secAlertTitle" }}' color="red" show-icon closable>
|
||||||
@@ -21,10 +21,7 @@
|
|||||||
<transition name="list" appear>
|
<transition name="list" appear>
|
||||||
<template>
|
<template>
|
||||||
<a-row v-if="!loadingStates.fetched">
|
<a-row v-if="!loadingStates.fetched">
|
||||||
<a-card
|
<div :style="{ minHeight: 'calc(100vh - 120px)' }"></div>
|
||||||
:style="{ textAlign: 'center', padding: '30px 0', marginTop: '10px', background: 'transparent', border: 'none' }">
|
|
||||||
<a-spin tip='{{ i18n "loading" }}'></a-spin>
|
|
||||||
</a-card>
|
|
||||||
</a-row>
|
</a-row>
|
||||||
<a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-else>
|
<a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-else>
|
||||||
<a-col>
|
<a-col>
|
||||||
@@ -82,10 +79,10 @@
|
|||||||
</template>
|
</template>
|
||||||
{{ template "settings/panel/subscription/general" . }}
|
{{ template "settings/panel/subscription/general" . }}
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
<a-tab-pane key="5" v-if="allSetting.subJsonEnable" :style="{ paddingTop: '20px' }">
|
<a-tab-pane key="5" v-if="allSetting.subJsonEnable || allSetting.subClashEnable" :style="{ paddingTop: '20px' }">
|
||||||
<template #tab>
|
<template #tab>
|
||||||
<a-icon type="code"></a-icon>
|
<a-icon type="code"></a-icon>
|
||||||
<span>{{ i18n "pages.settings.subSettings" }} (JSON)</span>
|
<span>{{ i18n "pages.settings.subSettings" }} (Formats)</span>
|
||||||
</template>
|
</template>
|
||||||
{{ template "settings/panel/subscription/json" . }}
|
{{ template "settings/panel/subscription/json" . }}
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
@@ -132,35 +129,14 @@
|
|||||||
datepickerList: [{ name: 'Gregorian (Standard)', value: 'gregorian' }, { name: 'Jalalian (شمسی)', value: 'jalalian' }],
|
datepickerList: [{ name: 'Gregorian (Standard)', value: 'gregorian' }, { name: 'Jalalian (شمسی)', value: 'jalalian' }],
|
||||||
remarkSample: '',
|
remarkSample: '',
|
||||||
defaultFragment: {
|
defaultFragment: {
|
||||||
tag: "fragment",
|
|
||||||
protocol: "freedom",
|
|
||||||
settings: {
|
|
||||||
domainStrategy: "AsIs",
|
|
||||||
fragment: {
|
|
||||||
packets: "tlshello",
|
packets: "tlshello",
|
||||||
length: "100-200",
|
length: "100-200",
|
||||||
interval: "10-20",
|
interval: "10-20",
|
||||||
maxSplit: "300-400"
|
maxSplit: "300-400"
|
||||||
}
|
|
||||||
},
|
},
|
||||||
streamSettings: {
|
defaultNoises: [
|
||||||
sockopt: {
|
{ type: "rand", packet: "10-20", delay: "10-16", applyTo: "ip" }
|
||||||
tcpKeepAliveIdle: 100,
|
|
||||||
tcpMptcp: true,
|
|
||||||
penetrate: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
defaultNoises: {
|
|
||||||
tag: "noises",
|
|
||||||
protocol: "freedom",
|
|
||||||
settings: {
|
|
||||||
domainStrategy: "AsIs",
|
|
||||||
noises: [
|
|
||||||
{ type: "rand", packet: "10-20", delay: "10-16", applyTo: "ip" },
|
|
||||||
],
|
],
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultMux: {
|
defaultMux: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
concurrency: 8,
|
concurrency: 8,
|
||||||
@@ -454,41 +430,41 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
fragmentPackets: {
|
fragmentPackets: {
|
||||||
get: function () { return this.fragment ? JSON.parse(this.allSetting.subJsonFragment).settings.fragment.packets : ""; },
|
get: function () { return this.fragment ? JSON.parse(this.allSetting.subJsonFragment).packets : ""; },
|
||||||
set: function (v) {
|
set: function (v) {
|
||||||
if (v != "") {
|
if (v != "") {
|
||||||
newFragment = JSON.parse(this.allSetting.subJsonFragment);
|
newFragment = JSON.parse(this.allSetting.subJsonFragment);
|
||||||
newFragment.settings.fragment.packets = v;
|
newFragment.packets = v;
|
||||||
this.allSetting.subJsonFragment = JSON.stringify(newFragment);
|
this.allSetting.subJsonFragment = JSON.stringify(newFragment);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
fragmentLength: {
|
fragmentLength: {
|
||||||
get: function () { return this.fragment ? JSON.parse(this.allSetting.subJsonFragment).settings.fragment.length : ""; },
|
get: function () { return this.fragment ? JSON.parse(this.allSetting.subJsonFragment).length : ""; },
|
||||||
set: function (v) {
|
set: function (v) {
|
||||||
if (v != "") {
|
if (v != "") {
|
||||||
newFragment = JSON.parse(this.allSetting.subJsonFragment);
|
newFragment = JSON.parse(this.allSetting.subJsonFragment);
|
||||||
newFragment.settings.fragment.length = v;
|
newFragment.length = v;
|
||||||
this.allSetting.subJsonFragment = JSON.stringify(newFragment);
|
this.allSetting.subJsonFragment = JSON.stringify(newFragment);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
fragmentInterval: {
|
fragmentInterval: {
|
||||||
get: function () { return this.fragment ? JSON.parse(this.allSetting.subJsonFragment).settings.fragment.interval : ""; },
|
get: function () { return this.fragment ? JSON.parse(this.allSetting.subJsonFragment).interval : ""; },
|
||||||
set: function (v) {
|
set: function (v) {
|
||||||
if (v != "") {
|
if (v != "") {
|
||||||
newFragment = JSON.parse(this.allSetting.subJsonFragment);
|
newFragment = JSON.parse(this.allSetting.subJsonFragment);
|
||||||
newFragment.settings.fragment.interval = v;
|
newFragment.interval = v;
|
||||||
this.allSetting.subJsonFragment = JSON.stringify(newFragment);
|
this.allSetting.subJsonFragment = JSON.stringify(newFragment);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
fragmentMaxSplit: {
|
fragmentMaxSplit: {
|
||||||
get: function () { return this.fragment ? JSON.parse(this.allSetting.subJsonFragment).settings.fragment.maxSplit : ""; },
|
get: function () { return this.fragment ? JSON.parse(this.allSetting.subJsonFragment).maxSplit : ""; },
|
||||||
set: function (v) {
|
set: function (v) {
|
||||||
if (v != "") {
|
if (v != "") {
|
||||||
newFragment = JSON.parse(this.allSetting.subJsonFragment);
|
newFragment = JSON.parse(this.allSetting.subJsonFragment);
|
||||||
newFragment.settings.fragment.maxSplit = v;
|
newFragment.maxSplit = v;
|
||||||
this.allSetting.subJsonFragment = JSON.stringify(newFragment);
|
this.allSetting.subJsonFragment = JSON.stringify(newFragment);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -507,13 +483,11 @@
|
|||||||
},
|
},
|
||||||
noisesArray: {
|
noisesArray: {
|
||||||
get() {
|
get() {
|
||||||
return this.noises ? JSON.parse(this.allSetting.subJsonNoises).settings.noises : [];
|
return this.noises ? JSON.parse(this.allSetting.subJsonNoises) : [];
|
||||||
},
|
},
|
||||||
set(value) {
|
set(value) {
|
||||||
if (this.noises) {
|
if (this.noises) {
|
||||||
const newNoises = JSON.parse(this.allSetting.subJsonNoises);
|
this.allSetting.subJsonNoises = JSON.stringify(value);
|
||||||
newNoises.settings.noises = value;
|
|
||||||
this.allSetting.subJsonNoises = JSON.stringify(newNoises);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,43 +3,58 @@
|
|||||||
<a-collapse-panel key="1" header='{{ i18n "pages.xray.generalConfigs"}}'>
|
<a-collapse-panel key="1" header='{{ i18n "pages.xray.generalConfigs"}}'>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.settings.subEnable"}}</template>
|
<template #title>{{ i18n "pages.settings.subEnable"}}</template>
|
||||||
<template #description>{{ i18n "pages.settings.subEnableDesc"}}</template>
|
<template #description>{{ i18n
|
||||||
|
"pages.settings.subEnableDesc"}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-switch v-model="allSetting.subEnable"></a-switch>
|
<a-switch v-model="allSetting.subEnable"></a-switch>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>JSON Subscription</template>
|
<template #title>JSON Subscription</template>
|
||||||
<template #description>{{ i18n "pages.settings.subJsonEnable"}}</template>
|
<template #description>{{ i18n
|
||||||
|
"pages.settings.subJsonEnable"}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-switch v-model="allSetting.subJsonEnable"></a-switch>
|
<a-switch v-model="allSetting.subJsonEnable"></a-switch>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
|
<a-setting-list-item paddings="small">
|
||||||
|
<template #title>Clash / Mihomo Subscription</template>
|
||||||
|
<template #description>Enable direct Clash and Mihomo YAML
|
||||||
|
subscriptions.</template>
|
||||||
|
<template #control>
|
||||||
|
<a-switch v-model="allSetting.subClashEnable"></a-switch>
|
||||||
|
</template>
|
||||||
|
</a-setting-list-item>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.settings.subListen"}}</template>
|
<template #title>{{ i18n "pages.settings.subListen"}}</template>
|
||||||
<template #description>{{ i18n "pages.settings.subListenDesc"}}</template>
|
<template #description>{{ i18n
|
||||||
|
"pages.settings.subListenDesc"}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-input type="text" v-model="allSetting.subListen"></a-input>
|
<a-input type="text" v-model="allSetting.subListen"></a-input>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.settings.subDomain"}}</template>
|
<template #title>{{ i18n "pages.settings.subDomain"}}</template>
|
||||||
<template #description>{{ i18n "pages.settings.subDomainDesc"}}</template>
|
<template #description>{{ i18n
|
||||||
|
"pages.settings.subDomainDesc"}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-input type="text" v-model="allSetting.subDomain"></a-input>
|
<a-input type="text" v-model="allSetting.subDomain"></a-input>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.settings.subPort"}}</template>
|
<template #title>{{ i18n "pages.settings.subPort"}}</template>
|
||||||
<template #description>{{ i18n "pages.settings.subPortDesc"}}</template>
|
<template #description>{{ i18n
|
||||||
|
"pages.settings.subPortDesc"}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-input-number v-model="allSetting.subPort" :min="1" :min="65535"
|
<a-input-number v-model="allSetting.subPort" :min="1"
|
||||||
|
:min="65535"
|
||||||
:style="{ width: '100%' }"></a-input-number>
|
:style="{ width: '100%' }"></a-input-number>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.settings.subPath"}}</template>
|
<template #title>{{ i18n "pages.settings.subPath"}}</template>
|
||||||
<template #description>{{ i18n "pages.settings.subPathDesc"}}</template>
|
<template #description>{{ i18n
|
||||||
|
"pages.settings.subPathDesc"}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-input type="text" v-model="allSetting.subPath"
|
<a-input type="text" v-model="allSetting.subPath"
|
||||||
@input="allSetting.subPath = ((typeof $event === 'string' ? $event : ($event && $event.target ? $event.target.value : '')) || '').replace(/[:*]/g, '')"
|
@input="allSetting.subPath = ((typeof $event === 'string' ? $event : ($event && $event.target ? $event.target.value : '')) || '').replace(/[:*]/g, '')"
|
||||||
@@ -49,9 +64,11 @@
|
|||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.settings.subURI"}}</template>
|
<template #title>{{ i18n "pages.settings.subURI"}}</template>
|
||||||
<template #description>{{ i18n "pages.settings.subURIDesc"}}</template>
|
<template #description>{{ i18n
|
||||||
|
"pages.settings.subURIDesc"}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-input type="text" placeholder="(http|https)://domain[:port]/path/"
|
<a-input type="text"
|
||||||
|
placeholder="(http|https)://domain[:port]/path/"
|
||||||
v-model="allSetting.subURI"></a-input>
|
v-model="allSetting.subURI"></a-input>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
@@ -59,14 +76,16 @@
|
|||||||
<a-collapse-panel key="2" header='{{ i18n "pages.settings.information" }}'>
|
<a-collapse-panel key="2" header='{{ i18n "pages.settings.information" }}'>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.settings.subEncrypt"}}</template>
|
<template #title>{{ i18n "pages.settings.subEncrypt"}}</template>
|
||||||
<template #description>{{ i18n "pages.settings.subEncryptDesc"}}</template>
|
<template #description>{{ i18n
|
||||||
|
"pages.settings.subEncryptDesc"}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-switch v-model="allSetting.subEncrypt"></a-switch>
|
<a-switch v-model="allSetting.subEncrypt"></a-switch>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.settings.subShowInfo"}}</template>
|
<template #title>{{ i18n "pages.settings.subShowInfo"}}</template>
|
||||||
<template #description>{{ i18n "pages.settings.subShowInfoDesc"}}</template>
|
<template #description>{{ i18n
|
||||||
|
"pages.settings.subShowInfoDesc"}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-switch v-model="allSetting.subShowInfo"></a-switch>
|
<a-switch v-model="allSetting.subShowInfo"></a-switch>
|
||||||
</template>
|
</template>
|
||||||
@@ -74,59 +93,72 @@
|
|||||||
<a-divider>{{ i18n "pages.xray.basicTemplate"}}</a-divider>
|
<a-divider>{{ i18n "pages.xray.basicTemplate"}}</a-divider>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.settings.subTitle"}}</template>
|
<template #title>{{ i18n "pages.settings.subTitle"}}</template>
|
||||||
<template #description>{{ i18n "pages.settings.subTitleDesc"}}</template>
|
<template #description>{{ i18n
|
||||||
|
"pages.settings.subTitleDesc"}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-input type="text" v-model="allSetting.subTitle"></a-input>
|
<a-input type="text" v-model="allSetting.subTitle"></a-input>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.settings.subSupportUrl"}}</template>
|
<template #title>{{ i18n "pages.settings.subSupportUrl"}}</template>
|
||||||
<template #description>{{ i18n "pages.settings.subSupportUrlDesc"}}</template>
|
<template #description>{{ i18n
|
||||||
|
"pages.settings.subSupportUrlDesc"}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-input type="text" v-model="allSetting.subSupportUrl" placeholder="https://example.com"></a-input>
|
<a-input type="text" v-model="allSetting.subSupportUrl"
|
||||||
|
placeholder="https://example.com"></a-input>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.settings.subProfileUrl"}}</template>
|
<template #title>{{ i18n "pages.settings.subProfileUrl"}}</template>
|
||||||
<template #description>{{ i18n "pages.settings.subProfileUrlDesc"}}</template>
|
<template #description>{{ i18n
|
||||||
|
"pages.settings.subProfileUrlDesc"}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-input type="text" v-model="allSetting.subProfileUrl" placeholder="https://example.com"></a-input>
|
<a-input type="text" v-model="allSetting.subProfileUrl"
|
||||||
|
placeholder="https://example.com"></a-input>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.settings.subAnnounce"}}</template>
|
<template #title>{{ i18n "pages.settings.subAnnounce"}}</template>
|
||||||
<template #description>{{ i18n "pages.settings.subAnnounceDesc"}}</template>
|
<template #description>{{ i18n
|
||||||
|
"pages.settings.subAnnounceDesc"}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-textarea v-model="allSetting.subAnnounce"></a-textarea>
|
<a-textarea v-model="allSetting.subAnnounce"></a-textarea>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
<a-divider>{{ i18n "pages.xray.advancedTemplate"}} (Happ)</a-divider>
|
<a-divider>{{ i18n "pages.xray.advancedTemplate"}} (Happ)</a-divider>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.settings.subEnableRouting"}}</template>
|
<template #title>{{ i18n
|
||||||
<template #description>{{ i18n "pages.settings.subEnableRoutingDesc"}}</template>
|
"pages.settings.subEnableRouting"}}</template>
|
||||||
|
<template #description>{{ i18n
|
||||||
|
"pages.settings.subEnableRoutingDesc"}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-switch v-model="allSetting.subEnableRouting"></a-switch>
|
<a-switch v-model="allSetting.subEnableRouting"></a-switch>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.settings.subRoutingRules"}}</template>
|
<template #title>{{ i18n
|
||||||
<template #description>{{ i18n "pages.settings.subRoutingRulesDesc"}}</template>
|
"pages.settings.subRoutingRules"}}</template>
|
||||||
|
<template #description>{{ i18n
|
||||||
|
"pages.settings.subRoutingRulesDesc"}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-textarea v-model="allSetting.subRoutingRules" placeholder="happ://routing/add/..."></a-textarea>
|
<a-textarea v-model="allSetting.subRoutingRules"
|
||||||
|
placeholder="happ://routing/add/..."></a-textarea>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
</a-collapse-panel>
|
</a-collapse-panel>
|
||||||
<a-collapse-panel key="3" header='{{ i18n "pages.settings.certs" }}'>
|
<a-collapse-panel key="3" header='{{ i18n "pages.settings.certs" }}'>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.settings.subCertPath"}}</template>
|
<template #title>{{ i18n "pages.settings.subCertPath"}}</template>
|
||||||
<template #description>{{ i18n "pages.settings.subCertPathDesc"}}</template>
|
<template #description>{{ i18n
|
||||||
|
"pages.settings.subCertPathDesc"}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-input type="text" v-model="allSetting.subCertFile"></a-input>
|
<a-input type="text" v-model="allSetting.subCertFile"></a-input>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.settings.subKeyPath"}}</template>
|
<template #title>{{ i18n "pages.settings.subKeyPath"}}</template>
|
||||||
<template #description>{{ i18n "pages.settings.subKeyPathDesc"}}</template>
|
<template #description>{{ i18n
|
||||||
|
"pages.settings.subKeyPathDesc"}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-input type="text" v-model="allSetting.subKeyFile"></a-input>
|
<a-input type="text" v-model="allSetting.subKeyFile"></a-input>
|
||||||
</template>
|
</template>
|
||||||
@@ -135,9 +167,11 @@
|
|||||||
<a-collapse-panel key="4" header='{{ i18n "pages.settings.intervals"}}'>
|
<a-collapse-panel key="4" header='{{ i18n "pages.settings.intervals"}}'>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.settings.subUpdates"}}</template>
|
<template #title>{{ i18n "pages.settings.subUpdates"}}</template>
|
||||||
<template #description>{{ i18n "pages.settings.subUpdatesDesc"}}</template>
|
<template #description>{{ i18n
|
||||||
|
"pages.settings.subUpdatesDesc"}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-input-number :min="1" v-model="allSetting.subUpdates" :style="{ width: '100%' }"></a-input-number>
|
<a-input-number :min="1" v-model="allSetting.subUpdates"
|
||||||
|
:style="{ width: '100%' }"></a-input-number>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
</a-collapse-panel>
|
</a-collapse-panel>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{{define "settings/panel/subscription/json"}}
|
{{define "settings/panel/subscription/json"}}
|
||||||
<a-collapse default-active-key="1">
|
<a-collapse default-active-key="1">
|
||||||
<a-collapse-panel key="1" header='{{ i18n "pages.xray.generalConfigs"}}'>
|
<a-collapse-panel key="1" header='{{ i18n "pages.xray.generalConfigs"}}'>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small" v-if="allSetting.subJsonEnable">
|
||||||
<template #title>{{ i18n "pages.settings.subPath"}}</template>
|
<template #title>{{ i18n "pages.settings.subPath"}} (JSON)</template>
|
||||||
<template #description>{{ i18n "pages.settings.subPathDesc"}}</template>
|
<template #description>{{ i18n "pages.settings.subPathDesc"}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-input type="text" v-model="allSetting.subJsonPath"
|
<a-input type="text" v-model="allSetting.subJsonPath"
|
||||||
@@ -11,14 +11,32 @@
|
|||||||
placeholder="/json/"></a-input>
|
placeholder="/json/"></a-input>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small" v-if="allSetting.subJsonEnable">
|
||||||
<template #title>{{ i18n "pages.settings.subURI"}}</template>
|
<template #title>{{ i18n "pages.settings.subURI"}} (JSON)</template>
|
||||||
<template #description>{{ i18n "pages.settings.subURIDesc"}}</template>
|
<template #description>{{ i18n "pages.settings.subURIDesc"}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-input type="text" placeholder="(http|https)://domain[:port]/path/"
|
<a-input type="text" placeholder="(http|https)://domain[:port]/path/"
|
||||||
v-model="allSetting.subJsonURI"></a-input>
|
v-model="allSetting.subJsonURI"></a-input>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
|
<a-setting-list-item paddings="small" v-if="allSetting.subClashEnable">
|
||||||
|
<template #title>{{ i18n "pages.settings.subPath"}} (Clash)</template>
|
||||||
|
<template #description>{{ i18n "pages.settings.subPathDesc"}}</template>
|
||||||
|
<template #control>
|
||||||
|
<a-input type="text" v-model="allSetting.subClashPath"
|
||||||
|
@input="allSetting.subClashPath = ((typeof $event === 'string' ? $event : ($event && $event.target ? $event.target.value : '')) || '').replace(/[:*]/g, '')"
|
||||||
|
@blur="allSetting.subClashPath = (p => { p = p || '/'; if (!p.startsWith('/')) p='/' + p; if (!p.endsWith('/')) p += '/'; return p.replace(/\/+/g,'/'); })(allSetting.subClashPath)"
|
||||||
|
placeholder="/clash/"></a-input>
|
||||||
|
</template>
|
||||||
|
</a-setting-list-item>
|
||||||
|
<a-setting-list-item paddings="small" v-if="allSetting.subClashEnable">
|
||||||
|
<template #title>{{ i18n "pages.settings.subURI"}} (Clash)</template>
|
||||||
|
<template #description>{{ i18n "pages.settings.subURIDesc"}}</template>
|
||||||
|
<template #control>
|
||||||
|
<a-input type="text" placeholder="(http|https)://domain[:port]/path/"
|
||||||
|
v-model="allSetting.subClashURI"></a-input>
|
||||||
|
</template>
|
||||||
|
</a-setting-list-item>
|
||||||
</a-collapse-panel>
|
</a-collapse-panel>
|
||||||
<a-collapse-panel key="2" header='{{ i18n "pages.settings.fragment"}}'>
|
<a-collapse-panel key="2" header='{{ i18n "pages.settings.fragment"}}'>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
|
|||||||
@@ -6,6 +6,23 @@
|
|||||||
<script src="{{ .base_path }}assets/js/util/index.js?{{ .cur_ver }}"></script>
|
<script src="{{ .base_path }}assets/js/util/index.js?{{ .cur_ver }}"></script>
|
||||||
<script src="{{ .base_path }}assets/qrcode/qrious2.min.js?{{ .cur_ver }}"></script>
|
<script src="{{ .base_path }}assets/qrcode/qrious2.min.js?{{ .cur_ver }}"></script>
|
||||||
<style>
|
<style>
|
||||||
|
.subscription-page tr-qr-box.qr-box {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscription-page tr-qr-box.qr-box .qr-tag {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscription-page tr-qr-box.qr-box .qr-bg,
|
||||||
|
.subscription-page tr-qr-box.qr-box .qr-bg-sub {
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.subscription-page .subscription-link-box {
|
.subscription-page .subscription-link-box {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
@@ -83,7 +100,7 @@
|
|||||||
<a-form-item>
|
<a-form-item>
|
||||||
<a-space direction="vertical" align="center">
|
<a-space direction="vertical" align="center">
|
||||||
<a-row type="flex" :gutter="[8,8]" justify="center" style="width:100%">
|
<a-row type="flex" :gutter="[8,8]" justify="center" style="width:100%">
|
||||||
<a-col :xs="24" :sm="app.subJsonUrl ? 12 : 24" style="text-align:center;">
|
<a-col :xs="24" :sm="app.subJsonUrl || app.subClashUrl ? 12 : 24" style="text-align:center;">
|
||||||
<tr-qr-box class="qr-box">
|
<tr-qr-box class="qr-box">
|
||||||
<a-tag color="purple" class="qr-tag">
|
<a-tag color="purple" class="qr-tag">
|
||||||
<span>{{ i18n
|
<span>{{ i18n
|
||||||
@@ -112,6 +129,19 @@
|
|||||||
</tr-qr-bg>
|
</tr-qr-bg>
|
||||||
</tr-qr-box>
|
</tr-qr-box>
|
||||||
</a-col>
|
</a-col>
|
||||||
|
<a-col v-if="app.subClashUrl" :xs="24" :sm="12" style="text-align:center;">
|
||||||
|
<tr-qr-box class="qr-box">
|
||||||
|
<a-tag color="purple" class="qr-tag">
|
||||||
|
<span>Clash / Mihomo</span>
|
||||||
|
</a-tag>
|
||||||
|
<tr-qr-bg class="qr-bg-sub">
|
||||||
|
<tr-qr-bg-inner class="qr-bg-sub-inner">
|
||||||
|
<canvas id="qrcode-subclash" class="qr-cv" title='{{ i18n "copy" }}'
|
||||||
|
@click="copy(app.subClashUrl)"></canvas>
|
||||||
|
</tr-qr-bg-inner>
|
||||||
|
</tr-qr-bg>
|
||||||
|
</tr-qr-box>
|
||||||
|
</a-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
</a-space>
|
</a-space>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
@@ -180,8 +210,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
<a-form layout="vertical">
|
<a-form layout="vertical">
|
||||||
@@ -206,7 +234,7 @@
|
|||||||
<a-menu-item key="android-npvtunnel" @click="copy(app.subUrl)">NPV
|
<a-menu-item key="android-npvtunnel" @click="copy(app.subUrl)">NPV
|
||||||
Tunnel</a-menu-item>
|
Tunnel</a-menu-item>
|
||||||
<a-menu-item key="android-happ"
|
<a-menu-item key="android-happ"
|
||||||
@click="open('happ://add/' + encodeURIComponent(app.subUrl))">Happ</a-menu-item>
|
@click="open('happ://add/' + app.subUrl)">Happ</a-menu-item>
|
||||||
</a-menu>
|
</a-menu>
|
||||||
</a-dropdown>
|
</a-dropdown>
|
||||||
</a-col>
|
</a-col>
|
||||||
@@ -242,7 +270,7 @@
|
|||||||
</a-layout>
|
</a-layout>
|
||||||
|
|
||||||
<!-- Bootstrap data for external JS -->
|
<!-- Bootstrap data for external JS -->
|
||||||
<template id="subscription-data" data-sid="{{ .sId }}" data-sub-url="{{ .subUrl }}" data-subjson-url="{{ .subJsonUrl }}"
|
<template id="subscription-data" data-sid="{{ .sId }}" data-sub-url="{{ .subUrl }}" data-subjson-url="{{ .subJsonUrl }}" data-subclash-url="{{ .subClashUrl }}"
|
||||||
data-download="{{ .download }}" data-upload="{{ .upload }}" data-used="{{ .used }}" data-total="{{ .total }}"
|
data-download="{{ .download }}" data-upload="{{ .upload }}" data-used="{{ .used }}" data-total="{{ .total }}"
|
||||||
data-remained="{{ .remained }}" data-expire="{{ .expire }}" data-lastonline="{{ .lastOnline }}"
|
data-remained="{{ .remained }}" data-expire="{{ .expire }}" data-lastonline="{{ .lastOnline }}"
|
||||||
data-downloadbyte="{{ .downloadByte }}" data-uploadbyte="{{ .uploadByte }}" data-totalbyte="{{ .totalByte }}"
|
data-downloadbyte="{{ .downloadByte }}" data-uploadbyte="{{ .uploadByte }}" data-totalbyte="{{ .totalByte }}"
|
||||||
|
|||||||
@@ -4,18 +4,22 @@
|
|||||||
<a-row :xs="24" :sm="24" :lg="12">
|
<a-row :xs="24" :sm="24" :lg="12">
|
||||||
<a-alert type="warning" :style="{ textAlign: 'center' }">
|
<a-alert type="warning" :style="{ textAlign: 'center' }">
|
||||||
<template slot="message">
|
<template slot="message">
|
||||||
<a-icon type="exclamation-circle" theme="filled" :style="{ color: '#FFA031' }"></a-icon>
|
<a-icon type="exclamation-circle" theme="filled"
|
||||||
|
:style="{ color: '#FFA031' }"></a-icon>
|
||||||
<span>{{ i18n "pages.xray.generalConfigsDesc" }}</span>
|
<span>{{ i18n "pages.xray.generalConfigsDesc" }}</span>
|
||||||
</template>
|
</template>
|
||||||
</a-alert>
|
</a-alert>
|
||||||
</a-row>
|
</a-row>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.xray.FreedomStrategy" }}</template>
|
<template #title>{{ i18n "pages.xray.FreedomStrategy" }}</template>
|
||||||
<template #description>{{ i18n "pages.xray.FreedomStrategyDesc" }}</template>
|
<template #description>{{ i18n "pages.xray.FreedomStrategyDesc"
|
||||||
|
}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-select v-model="freedomStrategy" :dropdown-class-name="themeSwitcher.currentTheme"
|
<a-select v-model="freedomStrategy"
|
||||||
|
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||||
:style="{ width: '100%' }">
|
:style="{ width: '100%' }">
|
||||||
<a-select-option v-for="s in OutboundDomainStrategies" :value="s">
|
<a-select-option v-for="s in OutboundDomainStrategies"
|
||||||
|
:value="s">
|
||||||
<span>[[ s ]]</span>
|
<span>[[ s ]]</span>
|
||||||
</a-select-option>
|
</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
@@ -23,42 +27,63 @@
|
|||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.xray.RoutingStrategy" }}</template>
|
<template #title>{{ i18n "pages.xray.RoutingStrategy" }}</template>
|
||||||
<template #description>{{ i18n "pages.xray.RoutingStrategyDesc" }}</template>
|
<template #description>{{ i18n "pages.xray.RoutingStrategyDesc"
|
||||||
|
}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-select v-model="routingStrategy" :dropdown-class-name="themeSwitcher.currentTheme"
|
<a-select v-model="routingStrategy"
|
||||||
|
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||||
:style="{ width: '100%' }">
|
:style="{ width: '100%' }">
|
||||||
<a-select-option v-for="s in routingDomainStrategies" :value="s">
|
<a-select-option v-for="s in routingDomainStrategies"
|
||||||
|
:value="s">
|
||||||
<span>[[ s ]]</span>
|
<span>[[ s ]]</span>
|
||||||
</a-select-option>
|
</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
|
<a-setting-list-item paddings="small">
|
||||||
|
<template #title>{{ i18n "pages.xray.outboundTestUrl" }}</template>
|
||||||
|
<template #description>{{ i18n "pages.xray.outboundTestUrlDesc"
|
||||||
|
}}</template>
|
||||||
|
<template #control>
|
||||||
|
<a-input v-model="outboundTestUrl"
|
||||||
|
:placeholder="'https://www.google.com/generate_204'"
|
||||||
|
:style="{ width: '100%' }"></a-input>
|
||||||
|
</template>
|
||||||
|
</a-setting-list-item>
|
||||||
</a-collapse-panel>
|
</a-collapse-panel>
|
||||||
<a-collapse-panel key="2" header='{{ i18n "pages.xray.statistics" }}'>
|
<a-collapse-panel key="2" header='{{ i18n "pages.xray.statistics" }}'>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.xray.statsInboundUplink" }}</template>
|
<template #title>{{ i18n "pages.xray.statsInboundUplink"
|
||||||
<template #description>{{ i18n "pages.xray.statsInboundUplinkDesc" }}</template>
|
}}</template>
|
||||||
|
<template #description>{{ i18n "pages.xray.statsInboundUplinkDesc"
|
||||||
|
}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-switch v-model="statsInboundUplink"></a-switch>
|
<a-switch v-model="statsInboundUplink"></a-switch>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.xray.statsInboundDownlink" }}</template>
|
<template #title>{{ i18n "pages.xray.statsInboundDownlink"
|
||||||
<template #description>{{ i18n "pages.xray.statsInboundDownlinkDesc" }}</template>
|
}}</template>
|
||||||
|
<template #description>{{ i18n "pages.xray.statsInboundDownlinkDesc"
|
||||||
|
}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-switch v-model="statsInboundDownlink"></a-switch>
|
<a-switch v-model="statsInboundDownlink"></a-switch>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.xray.statsOutboundUplink" }}</template>
|
<template #title>{{ i18n "pages.xray.statsOutboundUplink"
|
||||||
<template #description>{{ i18n "pages.xray.statsOutboundUplinkDesc" }}</template>
|
}}</template>
|
||||||
|
<template #description>{{ i18n "pages.xray.statsOutboundUplinkDesc"
|
||||||
|
}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-switch v-model="statsOutboundUplink"></a-switch>
|
<a-switch v-model="statsOutboundUplink"></a-switch>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.xray.statsOutboundDownlink" }}</template>
|
<template #title>{{ i18n "pages.xray.statsOutboundDownlink"
|
||||||
<template #description>{{ i18n "pages.xray.statsOutboundDownlinkDesc" }}</template>
|
}}</template>
|
||||||
|
<template #description>{{ i18n
|
||||||
|
"pages.xray.statsOutboundDownlinkDesc" }}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-switch v-model="statsOutboundDownlink"></a-switch>
|
<a-switch v-model="statsOutboundDownlink"></a-switch>
|
||||||
</template>
|
</template>
|
||||||
@@ -68,16 +93,20 @@
|
|||||||
<a-row :xs="24" :sm="24" :lg="12">
|
<a-row :xs="24" :sm="24" :lg="12">
|
||||||
<a-alert type="warning" :style="{ textAlign: 'center' }">
|
<a-alert type="warning" :style="{ textAlign: 'center' }">
|
||||||
<template slot="message">
|
<template slot="message">
|
||||||
<a-icon type="exclamation-circle" theme="filled" :style="{ color: '#FFA031' }"></a-icon>
|
<a-icon type="exclamation-circle" theme="filled"
|
||||||
|
:style="{ color: '#FFA031' }"></a-icon>
|
||||||
<span>{{ i18n "pages.xray.logConfigsDesc" }}</span>
|
<span>{{ i18n "pages.xray.logConfigsDesc" }}</span>
|
||||||
</template>
|
</template>
|
||||||
</a-alert>
|
</a-alert>
|
||||||
</a-row>
|
</a-row>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.xray.logLevel" }}</template>
|
<template #title>{{ i18n "pages.xray.logLevel" }}</template>
|
||||||
<template #description>{{ i18n "pages.xray.logLevelDesc" }}</template>
|
<template #description>{{ i18n "pages.xray.logLevelDesc"
|
||||||
|
}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-select v-model="logLevel" :dropdown-class-name="themeSwitcher.currentTheme" :style="{ width: '100%' }">
|
<a-select v-model="logLevel"
|
||||||
|
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||||
|
:style="{ width: '100%' }">
|
||||||
<a-select-option v-for="s in log.loglevel" :value="s">
|
<a-select-option v-for="s in log.loglevel" :value="s">
|
||||||
<span>[[ s ]]</span>
|
<span>[[ s ]]</span>
|
||||||
</a-select-option>
|
</a-select-option>
|
||||||
@@ -86,10 +115,13 @@
|
|||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.xray.accessLog" }}</template>
|
<template #title>{{ i18n "pages.xray.accessLog" }}</template>
|
||||||
<template #description>{{ i18n "pages.xray.accessLogDesc" }}</template>
|
<template #description>{{ i18n "pages.xray.accessLogDesc"
|
||||||
|
}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-select v-model="accessLog" :dropdown-class-name="themeSwitcher.currentTheme" :style="{ width: '100%' }">
|
<a-select v-model="accessLog"
|
||||||
<a-select-option value=''>
|
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||||
|
:style="{ width: '100%' }">
|
||||||
|
<a-select-option value>
|
||||||
<span>Empty</span>
|
<span>Empty</span>
|
||||||
</a-select-option>
|
</a-select-option>
|
||||||
<a-select-option v-for="s in log.access" :value="s">
|
<a-select-option v-for="s in log.access" :value="s">
|
||||||
@@ -100,10 +132,13 @@
|
|||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.xray.errorLog" }}</template>
|
<template #title>{{ i18n "pages.xray.errorLog" }}</template>
|
||||||
<template #description>{{ i18n "pages.xray.errorLogDesc" }}</template>
|
<template #description>{{ i18n "pages.xray.errorLogDesc"
|
||||||
|
}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-select v-model="errorLog" :dropdown-class-name="themeSwitcher.currentTheme" :style="{ width: '100%' }">
|
<a-select v-model="errorLog"
|
||||||
<a-select-option value=''>
|
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||||
|
:style="{ width: '100%' }">
|
||||||
|
<a-select-option value>
|
||||||
<span>Empty</span>
|
<span>Empty</span>
|
||||||
</a-select-option>
|
</a-select-option>
|
||||||
<a-select-option v-for="s in log.error" :value="s">
|
<a-select-option v-for="s in log.error" :value="s">
|
||||||
@@ -114,11 +149,13 @@
|
|||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.xray.maskAddress" }}</template>
|
<template #title>{{ i18n "pages.xray.maskAddress" }}</template>
|
||||||
<template #description>{{ i18n "pages.xray.maskAddressDesc" }}</template>
|
<template #description>{{ i18n "pages.xray.maskAddressDesc"
|
||||||
|
}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-select v-model="maskAddressLog" :dropdown-class-name="themeSwitcher.currentTheme"
|
<a-select v-model="maskAddressLog"
|
||||||
|
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||||
:style="{ width: '100%' }">
|
:style="{ width: '100%' }">
|
||||||
<a-select-option value=''>
|
<a-select-option value>
|
||||||
<span>Empty</span>
|
<span>Empty</span>
|
||||||
</a-select-option>
|
</a-select-option>
|
||||||
<a-select-option v-for="s in log.maskAddress" :value="s">
|
<a-select-option v-for="s in log.maskAddress" :value="s">
|
||||||
@@ -139,7 +176,8 @@
|
|||||||
<a-row :xs="24" :sm="24" :lg="12">
|
<a-row :xs="24" :sm="24" :lg="12">
|
||||||
<a-alert type="warning" :style="{ textAlign: 'center' }">
|
<a-alert type="warning" :style="{ textAlign: 'center' }">
|
||||||
<template slot="message">
|
<template slot="message">
|
||||||
<a-icon type="exclamation-circle" theme="filled" :style="{ color: '#FFA031' }"></a-icon>
|
<a-icon type="exclamation-circle" theme="filled"
|
||||||
|
:style="{ color: '#FFA031' }"></a-icon>
|
||||||
<span>{{ i18n "pages.xray.blockConfigsDesc" }}</span>
|
<span>{{ i18n "pages.xray.blockConfigsDesc" }}</span>
|
||||||
</template>
|
</template>
|
||||||
</a-alert>
|
</a-alert>
|
||||||
@@ -153,17 +191,21 @@
|
|||||||
<a-row :xs="24" :sm="24" :lg="12">
|
<a-row :xs="24" :sm="24" :lg="12">
|
||||||
<a-alert type="warning" :style="{ textAlign: 'center' }">
|
<a-alert type="warning" :style="{ textAlign: 'center' }">
|
||||||
<template slot="message">
|
<template slot="message">
|
||||||
<a-icon type="exclamation-circle" theme="filled" :style="{ color: '#FFA031' }"></a-icon>
|
<a-icon type="exclamation-circle" theme="filled"
|
||||||
<span>{{ i18n "pages.xray.blockConnectionsConfigsDesc" }}</span>
|
:style="{ color: '#FFA031' }"></a-icon>
|
||||||
|
<span>{{ i18n "pages.xray.blockConnectionsConfigsDesc"
|
||||||
|
}}</span>
|
||||||
</template>
|
</template>
|
||||||
</a-alert>
|
</a-alert>
|
||||||
</a-row>
|
</a-row>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.xray.blockips" }}</template>
|
<template #title>{{ i18n "pages.xray.blockips" }}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-select mode="tags" v-model="blockedIPs" :style="{ width: '100%' }"
|
<a-select mode="tags" v-model="blockedIPs"
|
||||||
|
:style="{ width: '100%' }"
|
||||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||||
<a-select-option :value="p.value" :label="p.label" v-for="p in settingsData.IPsOptions">
|
<a-select-option :value="p.value" :label="p.label"
|
||||||
|
v-for="p in settingsData.IPsOptions">
|
||||||
<span>[[ p.label ]]</span>
|
<span>[[ p.label ]]</span>
|
||||||
</a-select-option>
|
</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
@@ -172,28 +214,35 @@
|
|||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.xray.blockdomains" }}</template>
|
<template #title>{{ i18n "pages.xray.blockdomains" }}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-select mode="tags" v-model="blockedDomains" :style="{ width: '100%' }"
|
<a-select mode="tags" v-model="blockedDomains"
|
||||||
|
:style="{ width: '100%' }"
|
||||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||||
<a-select-option :value="p.value" :label="p.label" v-for="p in settingsData.BlockDomainsOptions">
|
<a-select-option :value="p.value" :label="p.label"
|
||||||
|
v-for="p in settingsData.BlockDomainsOptions">
|
||||||
<span>[[ p.label ]]</span>
|
<span>[[ p.label ]]</span>
|
||||||
</a-select-option>
|
</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
<a-row :xs="24" :sm="24" :lg="12">
|
<a-row :xs="24" :sm="24" :lg="12">
|
||||||
<a-alert type="warning" :style="{ textAlign: 'center', marginTop: '20px' }">
|
<a-alert type="warning"
|
||||||
|
:style="{ textAlign: 'center', marginTop: '20px' }">
|
||||||
<template slot="message">
|
<template slot="message">
|
||||||
<a-icon type="exclamation-circle" theme="filled" :style="{ color: '#FFA031' }"></a-icon>
|
<a-icon type="exclamation-circle" theme="filled"
|
||||||
<span>{{ i18n "pages.xray.directConnectionsConfigsDesc" }}</span>
|
:style="{ color: '#FFA031' }"></a-icon>
|
||||||
|
<span>{{ i18n "pages.xray.directConnectionsConfigsDesc"
|
||||||
|
}}</span>
|
||||||
</template>
|
</template>
|
||||||
</a-alert>
|
</a-alert>
|
||||||
</a-row>
|
</a-row>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.xray.directips" }}</template>
|
<template #title>{{ i18n "pages.xray.directips" }}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-select mode="tags" :style="{ width: '100%' }" v-model="directIPs"
|
<a-select mode="tags" :style="{ width: '100%' }"
|
||||||
|
v-model="directIPs"
|
||||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||||
<a-select-option :value="p.value" :label="p.label" v-for="p in settingsData.IPsOptions">
|
<a-select-option :value="p.value" :label="p.label"
|
||||||
|
v-for="p in settingsData.IPsOptions">
|
||||||
<span>[[ p.label ]]</span>
|
<span>[[ p.label ]]</span>
|
||||||
</a-select-option>
|
</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
@@ -202,18 +251,22 @@
|
|||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.xray.directdomains" }}</template>
|
<template #title>{{ i18n "pages.xray.directdomains" }}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-select mode="tags" :style="{ width: '100%' }" v-model="directDomains"
|
<a-select mode="tags" :style="{ width: '100%' }"
|
||||||
|
v-model="directDomains"
|
||||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||||
<a-select-option :value="p.value" :label="p.label" v-for="p in settingsData.DomainsOptions">
|
<a-select-option :value="p.value" :label="p.label"
|
||||||
|
v-for="p in settingsData.DomainsOptions">
|
||||||
<span>[[ p.label ]]</span>
|
<span>[[ p.label ]]</span>
|
||||||
</a-select-option>
|
</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
<a-row :xs="24" :sm="24" :lg="12">
|
<a-row :xs="24" :sm="24" :lg="12">
|
||||||
<a-alert type="warning" :style="{ textAlign: 'center', marginTop: '20px' }">
|
<a-alert type="warning"
|
||||||
|
:style="{ textAlign: 'center', marginTop: '20px' }">
|
||||||
<template slot="message">
|
<template slot="message">
|
||||||
<a-icon type="exclamation-circle" theme="filled" :style="{ color: '#FFA031' }"></a-icon>
|
<a-icon type="exclamation-circle" theme="filled"
|
||||||
|
:style="{ color: '#FFA031' }"></a-icon>
|
||||||
<span>{{ i18n "pages.xray.ipv4RoutingDesc" }}</span>
|
<span>{{ i18n "pages.xray.ipv4RoutingDesc" }}</span>
|
||||||
</template>
|
</template>
|
||||||
</a-alert>
|
</a-alert>
|
||||||
@@ -221,18 +274,22 @@
|
|||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.xray.ipv4Routing" }}</template>
|
<template #title>{{ i18n "pages.xray.ipv4Routing" }}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-select mode="tags" :style="{ width: '100%' }" v-model="ipv4Domains"
|
<a-select mode="tags" :style="{ width: '100%' }"
|
||||||
|
v-model="ipv4Domains"
|
||||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||||
<a-select-option :value="p.value" :label="p.label" v-for="p in settingsData.ServicesOptions">
|
<a-select-option :value="p.value" :label="p.label"
|
||||||
|
v-for="p in settingsData.ServicesOptions">
|
||||||
<span>[[ p.label ]]</span>
|
<span>[[ p.label ]]</span>
|
||||||
</a-select-option>
|
</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
<a-row :xs="24" :sm="24" :lg="12">
|
<a-row :xs="24" :sm="24" :lg="12">
|
||||||
<a-alert type="warning" :style="{ textAlign: 'center', marginTop: '20px' }">
|
<a-alert type="warning"
|
||||||
|
:style="{ textAlign: 'center', marginTop: '20px' }">
|
||||||
<template slot="message">
|
<template slot="message">
|
||||||
<a-icon type="exclamation-circle" theme="filled" :style="{ color: '#FFA031' }"></a-icon>
|
<a-icon type="exclamation-circle" theme="filled"
|
||||||
|
:style="{ color: '#FFA031' }"></a-icon>
|
||||||
{{ i18n "pages.xray.warpRoutingDesc" }}
|
{{ i18n "pages.xray.warpRoutingDesc" }}
|
||||||
</template>
|
</template>
|
||||||
</a-alert>
|
</a-alert>
|
||||||
@@ -241,20 +298,43 @@
|
|||||||
<template #title>{{ i18n "pages.xray.warpRouting" }}</template>
|
<template #title>{{ i18n "pages.xray.warpRouting" }}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<template v-if="WarpExist">
|
<template v-if="WarpExist">
|
||||||
<a-select mode="tags" :style="{ width: '100%' }" v-model="warpDomains"
|
<a-select mode="tags" :style="{ width: '100%' }"
|
||||||
|
v-model="warpDomains"
|
||||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||||
<a-select-option :value="p.value" :label="p.label" v-for="p in settingsData.ServicesOptions">
|
<a-select-option :value="p.value" :label="p.label"
|
||||||
|
v-for="p in settingsData.ServicesOptions">
|
||||||
<span>[[ p.label ]]</span>
|
<span>[[ p.label ]]</span>
|
||||||
</a-select-option>
|
</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<a-button type="primary" icon="cloud" @click="showWarp()">WARP</a-button>
|
<a-button type="primary" icon="cloud"
|
||||||
|
@click="showWarp()">WARP</a-button>
|
||||||
|
</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>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
</a-collapse-panel>
|
</a-collapse-panel>
|
||||||
<a-collapse-panel key="6" header='{{ i18n "pages.settings.resetDefaultConfig"}}'>
|
<a-collapse-panel key="6"
|
||||||
|
header='{{ i18n "pages.settings.resetDefaultConfig"}}'>
|
||||||
<a-space direction="horizontal" :style="{ padding: '0 20px' }">
|
<a-space direction="horizontal" :style="{ padding: '0 20px' }">
|
||||||
<a-button type="danger" @click="resetXrayConfigToDefault">
|
<a-button type="danger" @click="resetXrayConfigToDefault">
|
||||||
<span>{{ i18n "pages.settings.resetDefaultConfig" }}</span>
|
<span>{{ i18n "pages.settings.resetDefaultConfig" }}</span>
|
||||||
|
|||||||
@@ -4,17 +4,24 @@
|
|||||||
<a-col :xs="12" :sm="12" :lg="12">
|
<a-col :xs="12" :sm="12" :lg="12">
|
||||||
<a-space direction="horizontal" size="small">
|
<a-space direction="horizontal" size="small">
|
||||||
<a-button type="primary" icon="plus" @click="addOutbound">
|
<a-button type="primary" icon="plus" @click="addOutbound">
|
||||||
<span v-if="!isMobile">{{ i18n "pages.xray.outbound.addOutbound" }}</span>
|
<span v-if="!isMobile">{{ i18n
|
||||||
|
"pages.xray.outbound.addOutbound" }}</span>
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-button type="primary" icon="cloud" @click="showWarp()">WARP</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-space>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :xs="12" :sm="12" :lg="12" :style="{ textAlign: 'right' }">
|
<a-col :xs="12" :sm="12" :lg="12" :style="{ textAlign: 'right' }">
|
||||||
<a-button-group>
|
<a-button-group>
|
||||||
<a-button icon="sync" @click="refreshOutboundTraffic()" :loading="refreshing"></a-button>
|
<a-button icon="sync" @click="refreshOutboundTraffic()"
|
||||||
<a-popconfirm placement="topRight" @confirm="resetOutboundTraffic(-1)"
|
:loading="refreshing"></a-button>
|
||||||
|
<a-popconfirm placement="topRight"
|
||||||
|
@confirm="resetOutboundTraffic(-1)"
|
||||||
title='{{ i18n "pages.inbounds.resetTrafficContent"}}'
|
title='{{ i18n "pages.inbounds.resetTrafficContent"}}'
|
||||||
:overlay-class-name="themeSwitcher.currentTheme" ok-text='{{ i18n "reset"}}'
|
:overlay-class-name="themeSwitcher.currentTheme"
|
||||||
|
ok-text='{{ i18n "reset"}}'
|
||||||
cancel-text='{{ i18n "cancel"}}'>
|
cancel-text='{{ i18n "cancel"}}'>
|
||||||
<a-icon slot="icon" type="question-circle-o"
|
<a-icon slot="icon" type="question-circle-o"
|
||||||
:style="{ color: themeSwitcher.isDarkTheme ? '#008771' : '#008771' }"></a-icon>
|
:style="{ color: themeSwitcher.isDarkTheme ? '#008771' : '#008771' }"></a-icon>
|
||||||
@@ -23,8 +30,10 @@
|
|||||||
</a-button-group>
|
</a-button-group>
|
||||||
</a-col>
|
</a-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
<a-table :columns="outboundColumns" bordered :row-key="r => r.key" :data-source="outboundData"
|
<a-table :columns="outboundColumns" bordered :row-key="r => r.key"
|
||||||
:scroll="isMobile ? {} : { x: 800 }" :pagination="false" :indent-size="0"
|
:data-source="outboundData"
|
||||||
|
:scroll="isMobile ? {} : { x: 800 }" :pagination="false"
|
||||||
|
:indent-size="0"
|
||||||
:locale='{ filterConfirm: `{{ i18n "confirm" }}`, filterReset: `{{ i18n "reset" }}` }'>
|
:locale='{ filterConfirm: `{{ i18n "confirm" }}`, filterReset: `{{ i18n "reset" }}` }'>
|
||||||
<template slot="action" slot-scope="text, outbound, index">
|
<template slot="action" slot-scope="text, outbound, index">
|
||||||
<span>[[ index+1 ]]</span>
|
<span>[[ index+1 ]]</span>
|
||||||
@@ -32,7 +41,8 @@
|
|||||||
<a-icon @click="e => e.preventDefault()" type="more"
|
<a-icon @click="e => e.preventDefault()" type="more"
|
||||||
:style="{ fontSize: '16px', textDecoration: 'bold' }"></a-icon>
|
:style="{ fontSize: '16px', textDecoration: 'bold' }"></a-icon>
|
||||||
<a-menu slot="overlay" :theme="themeSwitcher.currentTheme">
|
<a-menu slot="overlay" :theme="themeSwitcher.currentTheme">
|
||||||
<a-menu-item v-if="index>0" @click="setFirstOutbound(index)">
|
<a-menu-item v-if="index>0"
|
||||||
|
@click="setFirstOutbound(index)">
|
||||||
<a-icon type="vertical-align-top"></a-icon>
|
<a-icon type="vertical-align-top"></a-icon>
|
||||||
<span>{{ i18n "pages.xray.rules.first"}}</span>
|
<span>{{ i18n "pages.xray.rules.first"}}</span>
|
||||||
</a-menu-item>
|
</a-menu-item>
|
||||||
@@ -56,21 +66,64 @@
|
|||||||
</a-dropdown>
|
</a-dropdown>
|
||||||
</template>
|
</template>
|
||||||
<template slot="address" slot-scope="text, outbound, index">
|
<template slot="address" slot-scope="text, outbound, index">
|
||||||
<p :style="{ margin: '0 5px' }" v-for="addr in findOutboundAddress(outbound)">[[ addr ]]</p>
|
<p :style="{ margin: '0 5px' }"
|
||||||
|
v-for="addr in findOutboundAddress(outbound)">[[ addr ]]</p>
|
||||||
</template>
|
</template>
|
||||||
<template slot="protocol" slot-scope="text, outbound, index">
|
<template slot="protocol" slot-scope="text, outbound, index">
|
||||||
<a-tag :style="{ margin: '0' }" color="purple">[[ outbound.protocol ]]</a-tag>
|
<a-tag :style="{ margin: '0' }" color="purple">[[ outbound.protocol
|
||||||
|
]]</a-tag>
|
||||||
<template
|
<template
|
||||||
v-if="[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(outbound.protocol)">
|
v-if="[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(outbound.protocol)">
|
||||||
<a-tag :style="{ margin: '0' }" color="blue">[[ outbound.streamSettings.network ]]</a-tag>
|
<a-tag :style="{ margin: '0' }" color="blue">[[
|
||||||
<a-tag :style="{ margin: '0' }" v-if="outbound.streamSettings.security=='tls'" color="green">tls</a-tag>
|
outbound.streamSettings.network ]]</a-tag>
|
||||||
<a-tag :style="{ margin: '0' }" v-if="outbound.streamSettings.security=='reality'"
|
<a-tag :style="{ margin: '0' }"
|
||||||
|
v-if="outbound.streamSettings.security=='tls'"
|
||||||
|
color="green">tls</a-tag>
|
||||||
|
<a-tag :style="{ margin: '0' }"
|
||||||
|
v-if="outbound.streamSettings.security=='reality'"
|
||||||
color="green">reality</a-tag>
|
color="green">reality</a-tag>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
<template slot="traffic" slot-scope="text, outbound, index">
|
<template slot="traffic" slot-scope="text, outbound, index">
|
||||||
<a-tag color="green">[[ findOutboundTraffic(outbound) ]]</a-tag>
|
<a-tag color="green">[[ findOutboundTraffic(outbound) ]]</a-tag>
|
||||||
</template>
|
</template>
|
||||||
|
<template slot="test" slot-scope="text, outbound, index">
|
||||||
|
<a-tooltip>
|
||||||
|
<template slot="title">{{ i18n "pages.xray.outbound.test"
|
||||||
|
}}</template>
|
||||||
|
<a-button
|
||||||
|
type="primary"
|
||||||
|
shape="circle"
|
||||||
|
icon="thunderbolt"
|
||||||
|
:loading="outboundTestStates[index] && outboundTestStates[index].testing"
|
||||||
|
@click="testOutbound(index)"
|
||||||
|
:disabled="(outbound.protocol === 'blackhole' || outbound.tag === 'blocked') || (outboundTestStates[index] && outboundTestStates[index].testing)">
|
||||||
|
</a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
</template>
|
||||||
|
<template slot="testResult" slot-scope="text, outbound, index">
|
||||||
|
<div
|
||||||
|
v-if="outboundTestStates[index] && outboundTestStates[index].result">
|
||||||
|
<a-tag v-if="outboundTestStates[index].result.success"
|
||||||
|
color="green">
|
||||||
|
[[ outboundTestStates[index].result.delay ]]ms
|
||||||
|
<span v-if="outboundTestStates[index].result.statusCode">
|
||||||
|
([[ outboundTestStates[index].result.statusCode
|
||||||
|
]])</span>
|
||||||
|
</a-tag>
|
||||||
|
<a-tooltip v-else
|
||||||
|
:title="outboundTestStates[index].result.error">
|
||||||
|
<a-tag color="red">
|
||||||
|
Failed
|
||||||
|
</a-tag>
|
||||||
|
</a-tooltip>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
v-else-if="outboundTestStates[index] && outboundTestStates[index].testing">
|
||||||
|
<a-icon type="loading" />
|
||||||
|
</span>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
</a-table>
|
</a-table>
|
||||||
</a-space>
|
</a-space>
|
||||||
{{end}}
|
{{end}}
|
||||||
+197
-35
@@ -1,7 +1,10 @@
|
|||||||
{{ template "page/head_start" .}}
|
{{ template "page/head_start" .}}
|
||||||
<link rel="stylesheet" href="{{ .base_path }}assets/codemirror/codemirror.min.css?{{ .cur_ver }}">
|
<link rel="stylesheet"
|
||||||
<link rel="stylesheet" href="{{ .base_path }}assets/codemirror/fold/foldgutter.css">
|
href="{{ .base_path }}assets/codemirror/codemirror.min.css?{{ .cur_ver }}">
|
||||||
<link rel="stylesheet" href="{{ .base_path }}assets/codemirror/xq.min.css?{{ .cur_ver }}">
|
<link rel="stylesheet"
|
||||||
|
href="{{ .base_path }}assets/codemirror/fold/foldgutter.css">
|
||||||
|
<link rel="stylesheet"
|
||||||
|
href="{{ .base_path }}assets/codemirror/xq.min.css?{{ .cur_ver }}">
|
||||||
<link rel="stylesheet" href="{{ .base_path }}assets/codemirror/lint/lint.css">
|
<link rel="stylesheet" href="{{ .base_path }}assets/codemirror/lint/lint.css">
|
||||||
{{ template "page/head_end" .}}
|
{{ template "page/head_end" .}}
|
||||||
|
|
||||||
@@ -10,35 +13,41 @@
|
|||||||
<a-sidebar></a-sidebar>
|
<a-sidebar></a-sidebar>
|
||||||
<a-layout id="content-layout">
|
<a-layout id="content-layout">
|
||||||
<a-layout-content>
|
<a-layout-content>
|
||||||
<a-spin :spinning="loadingStates.spinning" :delay="500" tip='{{ i18n "loading"}}'>
|
<a-spin :spinning="loadingStates.spinning" :delay="500"
|
||||||
|
tip='{{ i18n "loading"}}' size="large">
|
||||||
<transition name="list" appear>
|
<transition name="list" appear>
|
||||||
<a-alert type="error" v-if="showAlert && loadingStates.fetched" :style="{ marginBottom: '10px' }"
|
<a-alert type="error" v-if="showAlert && loadingStates.fetched"
|
||||||
message='{{ i18n "secAlertTitle" }}' color="red" description='{{ i18n "secAlertSsl" }}' show-icon closable>
|
:style="{ marginBottom: '10px' }"
|
||||||
|
message='{{ i18n "secAlertTitle" }}' color="red"
|
||||||
|
description='{{ i18n "secAlertSsl" }}' show-icon closable>
|
||||||
</a-alert>
|
</a-alert>
|
||||||
</transition>
|
</transition>
|
||||||
<transition name="list" appear>
|
<transition name="list" appear>
|
||||||
<a-row v-if="!loadingStates.fetched">
|
<a-row v-if="!loadingStates.fetched">
|
||||||
<a-card
|
<div :style="{ minHeight: 'calc(100vh - 120px)' }"></div>
|
||||||
:style="{ textAlign: 'center', padding: '30px 0', marginTop: '10px', background: 'transparent', border: 'none' }">
|
|
||||||
<a-spin tip='{{ i18n "loading" }}'></a-spin>
|
|
||||||
</a-card>
|
|
||||||
</a-row>
|
</a-row>
|
||||||
<a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-else>
|
<a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-else>
|
||||||
<a-col>
|
<a-col>
|
||||||
<a-card hoverable>
|
<a-card hoverable>
|
||||||
<a-row :style="{ display: 'flex', flexWrap: 'wrap', alignItems: 'center' }">
|
<a-row
|
||||||
|
:style="{ display: 'flex', flexWrap: 'wrap', alignItems: 'center' }">
|
||||||
<a-col :xs="24" :sm="10" :style="{ padding: '4px' }">
|
<a-col :xs="24" :sm="10" :style="{ padding: '4px' }">
|
||||||
<a-space direction="horizontal">
|
<a-space direction="horizontal">
|
||||||
<a-button type="primary" :disabled="saveBtnDisable" @click="updateXraySetting">
|
<a-button type="primary" :disabled="saveBtnDisable"
|
||||||
|
@click="updateXraySetting">
|
||||||
{{ i18n "pages.xray.save" }}
|
{{ i18n "pages.xray.save" }}
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-button type="danger" :disabled="!saveBtnDisable" @click="restartXray">
|
<a-button type="danger" :disabled="!saveBtnDisable"
|
||||||
|
@click="restartXray">
|
||||||
{{ i18n "pages.xray.restart" }}
|
{{ i18n "pages.xray.restart" }}
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-popover v-if="restartResult" :overlay-class-name="themeSwitcher.currentTheme">
|
<a-popover v-if="restartResult"
|
||||||
<span slot="title">{{ i18n "pages.index.xrayErrorPopoverTitle" }}</span>
|
:overlay-class-name="themeSwitcher.currentTheme">
|
||||||
|
<span slot="title">{{ i18n
|
||||||
|
"pages.index.xrayErrorPopoverTitle" }}</span>
|
||||||
<template slot="content">
|
<template slot="content">
|
||||||
<span :style="{ maxWidth: '400px' }" v-for="line in restartResult.split('\n')">[[ line
|
<span :style="{ maxWidth: '400px' }"
|
||||||
|
v-for="line in restartResult.split('\n')">[[ line
|
||||||
]]</span>
|
]]</span>
|
||||||
</template>
|
</template>
|
||||||
<a-icon type="question-circle"></a-icon>
|
<a-icon type="question-circle"></a-icon>
|
||||||
@@ -48,10 +57,13 @@
|
|||||||
<a-col :xs="24" :sm="14">
|
<a-col :xs="24" :sm="14">
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<a-back-top :target="() => document.getElementById('content-layout')"
|
<a-back-top
|
||||||
|
:target="() => document.getElementById('content-layout')"
|
||||||
visibility-height="200"></a-back-top>
|
visibility-height="200"></a-back-top>
|
||||||
<a-alert type="warning" :style="{ float: 'right', width: 'fit-content' }"
|
<a-alert type="warning"
|
||||||
message='{{ i18n "pages.settings.infoDesc" }}' show-icon>
|
:style="{ float: 'right', width: 'fit-content' }"
|
||||||
|
message='{{ i18n "pages.settings.infoDesc" }}'
|
||||||
|
show-icon>
|
||||||
</a-alert>
|
</a-alert>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -60,7 +72,8 @@
|
|||||||
</a-card>
|
</a-card>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col>
|
<a-col>
|
||||||
<a-tabs default-active-key="tpl-basic" @change="(activeKey) => { this.changePage(activeKey); }"
|
<a-tabs default-active-key="tpl-basic"
|
||||||
|
@change="(activeKey) => { this.changePage(activeKey); }"
|
||||||
:class="themeSwitcher.currentTheme">
|
:class="themeSwitcher.currentTheme">
|
||||||
<a-tab-pane key="tpl-basic" :style="{ paddingTop: '20px' }">
|
<a-tab-pane key="tpl-basic" :style="{ paddingTop: '20px' }">
|
||||||
<template #tab>
|
<template #tab>
|
||||||
@@ -83,21 +96,24 @@
|
|||||||
</template>
|
</template>
|
||||||
{{ template "settings/xray/outbounds" . }}
|
{{ template "settings/xray/outbounds" . }}
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
<a-tab-pane key="tpl-reverse" :style="{ paddingTop: '20px' }" force-render="true">
|
<a-tab-pane key="tpl-reverse" :style="{ paddingTop: '20px' }"
|
||||||
|
force-render="true">
|
||||||
<template #tab>
|
<template #tab>
|
||||||
<a-icon type="import"></a-icon>
|
<a-icon type="import"></a-icon>
|
||||||
<span>{{ i18n "pages.xray.outbound.reverse"}}</span>
|
<span>{{ i18n "pages.xray.outbound.reverse"}}</span>
|
||||||
</template>
|
</template>
|
||||||
{{ template "settings/xray/reverse" . }}
|
{{ template "settings/xray/reverse" . }}
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
<a-tab-pane key="tpl-balancer" :style="{ paddingTop: '20px' }" force-render="true">
|
<a-tab-pane key="tpl-balancer" :style="{ paddingTop: '20px' }"
|
||||||
|
force-render="true">
|
||||||
<template #tab>
|
<template #tab>
|
||||||
<a-icon type="cluster"></a-icon>
|
<a-icon type="cluster"></a-icon>
|
||||||
<span>{{ i18n "pages.xray.Balancers"}}</span>
|
<span>{{ i18n "pages.xray.Balancers"}}</span>
|
||||||
</template>
|
</template>
|
||||||
{{ template "settings/xray/balancers" . }}
|
{{ template "settings/xray/balancers" . }}
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
<a-tab-pane key="tpl-dns" :style="{ paddingTop: '20px' }" force-render="true">
|
<a-tab-pane key="tpl-dns" :style="{ paddingTop: '20px' }"
|
||||||
|
force-render="true">
|
||||||
<template #tab>
|
<template #tab>
|
||||||
<a-icon type="database"></a-icon>
|
<a-icon type="database"></a-icon>
|
||||||
<span>DNS</span>
|
<span>DNS</span>
|
||||||
@@ -120,14 +136,18 @@
|
|||||||
</a-layout>
|
</a-layout>
|
||||||
</a-layout>
|
</a-layout>
|
||||||
{{template "page/body_scripts" .}}
|
{{template "page/body_scripts" .}}
|
||||||
<script src="{{ .base_path }}assets/js/model/outbound.js?{{ .cur_ver }}"></script>
|
<script
|
||||||
<script src="{{ .base_path }}assets/codemirror/codemirror.min.js?{{ .cur_ver }}"></script>
|
src="{{ .base_path }}assets/js/model/outbound.js?{{ .cur_ver }}"></script>
|
||||||
|
<script
|
||||||
|
src="{{ .base_path }}assets/codemirror/codemirror.min.js?{{ .cur_ver }}"></script>
|
||||||
<script src="{{ .base_path }}assets/codemirror/javascript.js"></script>
|
<script src="{{ .base_path }}assets/codemirror/javascript.js"></script>
|
||||||
<script src="{{ .base_path }}assets/codemirror/jshint.js"></script>
|
<script src="{{ .base_path }}assets/codemirror/jshint.js"></script>
|
||||||
<script src="{{ .base_path }}assets/codemirror/jsonlint.js"></script>
|
<script src="{{ .base_path }}assets/codemirror/jsonlint.js"></script>
|
||||||
<script src="{{ .base_path }}assets/codemirror/lint/lint.js"></script>
|
<script src="{{ .base_path }}assets/codemirror/lint/lint.js"></script>
|
||||||
<script src="{{ .base_path }}assets/codemirror/lint/javascript-lint.js"></script>
|
<script
|
||||||
<script src="{{ .base_path }}assets/codemirror/hint/javascript-hint.js"></script>
|
src="{{ .base_path }}assets/codemirror/lint/javascript-lint.js"></script>
|
||||||
|
<script
|
||||||
|
src="{{ .base_path }}assets/codemirror/hint/javascript-hint.js"></script>
|
||||||
<script src="{{ .base_path }}assets/codemirror/fold/foldcode.js"></script>
|
<script src="{{ .base_path }}assets/codemirror/fold/foldcode.js"></script>
|
||||||
<script src="{{ .base_path }}assets/codemirror/fold/foldgutter.js"></script>
|
<script src="{{ .base_path }}assets/codemirror/fold/foldgutter.js"></script>
|
||||||
<script src="{{ .base_path }}assets/codemirror/fold/brace-fold.js"></script>
|
<script src="{{ .base_path }}assets/codemirror/fold/brace-fold.js"></script>
|
||||||
@@ -143,6 +163,7 @@
|
|||||||
{{template "modals/dnsPresetsModal"}}
|
{{template "modals/dnsPresetsModal"}}
|
||||||
{{template "modals/fakednsModal"}}
|
{{template "modals/fakednsModal"}}
|
||||||
{{template "modals/warpModal"}}
|
{{template "modals/warpModal"}}
|
||||||
|
{{template "modals/nordModal"}}
|
||||||
<script>
|
<script>
|
||||||
const rulesColumns = [
|
const rulesColumns = [
|
||||||
{ title: "#", align: 'center', width: 15, scopedSlots: { customRender: 'action' } },
|
{ title: "#", align: 'center', width: 15, scopedSlots: { customRender: 'action' } },
|
||||||
@@ -181,11 +202,13 @@
|
|||||||
];
|
];
|
||||||
|
|
||||||
const outboundColumns = [
|
const outboundColumns = [
|
||||||
{ title: "#", align: 'center', width: 20, scopedSlots: { customRender: 'action' } },
|
{ title: "#", align: 'center', width: 60, scopedSlots: { customRender: 'action' } },
|
||||||
{ title: '{{ i18n "pages.xray.outbound.tag"}}', dataIndex: 'tag', align: 'center', width: 50 },
|
{ title: '{{ i18n "pages.xray.outbound.tag"}}', dataIndex: 'tag', align: 'center', width: 50 },
|
||||||
{ title: '{{ i18n "protocol"}}', align: 'center', width: 50, scopedSlots: { customRender: 'protocol' } },
|
{ title: '{{ i18n "protocol"}}', align: 'center', width: 50, scopedSlots: { customRender: 'protocol' } },
|
||||||
{ title: '{{ i18n "pages.xray.outbound.address"}}', align: 'center', width: 50, scopedSlots: { customRender: 'address' } },
|
{ title: '{{ i18n "pages.xray.outbound.address"}}', align: 'center', width: 50, scopedSlots: { customRender: 'address' } },
|
||||||
{ title: '{{ i18n "pages.inbounds.traffic" }}', align: 'center', width: 50, scopedSlots: { customRender: 'traffic' } },
|
{ title: '{{ i18n "pages.inbounds.traffic" }}', align: 'center', width: 50, scopedSlots: { customRender: 'traffic' } },
|
||||||
|
{ title: '{{ i18n "pages.xray.outbound.testResult" }}', align: 'center', width: 120, scopedSlots: { customRender: 'testResult' } },
|
||||||
|
{ title: '{{ i18n "pages.xray.outbound.test" }}', align: 'center', width: 60, scopedSlots: { customRender: 'test' } },
|
||||||
];
|
];
|
||||||
|
|
||||||
const reverseColumns = [
|
const reverseColumns = [
|
||||||
@@ -228,12 +251,16 @@
|
|||||||
},
|
},
|
||||||
oldXraySetting: '',
|
oldXraySetting: '',
|
||||||
xraySetting: '',
|
xraySetting: '',
|
||||||
|
outboundTestUrl: 'https://www.google.com/generate_204',
|
||||||
|
oldOutboundTestUrl: 'https://www.google.com/generate_204',
|
||||||
inboundTags: [],
|
inboundTags: [],
|
||||||
outboundsTraffic: [],
|
outboundsTraffic: [],
|
||||||
|
outboundTestStates: {}, // Track testing state and results for each outbound
|
||||||
saveBtnDisable: true,
|
saveBtnDisable: true,
|
||||||
refreshing: false,
|
refreshing: false,
|
||||||
restartResult: '',
|
restartResult: '',
|
||||||
showAlert: false,
|
showAlert: false,
|
||||||
|
customGeoAliasLabelSuffix: '{{ i18n "pages.index.customGeoAliasLabelSuffix" }}',
|
||||||
advSettings: 'xraySetting',
|
advSettings: 'xraySetting',
|
||||||
obsSettings: '',
|
obsSettings: '',
|
||||||
cm: null,
|
cm: null,
|
||||||
@@ -337,17 +364,17 @@
|
|||||||
},
|
},
|
||||||
defaultObservatory: {
|
defaultObservatory: {
|
||||||
subjectSelector: [],
|
subjectSelector: [],
|
||||||
probeURL: "http://www.google.com/gen_204",
|
probeURL: "https://www.google.com/generate_204",
|
||||||
probeInterval: "10m",
|
probeInterval: "1m",
|
||||||
enableConcurrency: true
|
enableConcurrency: true
|
||||||
},
|
},
|
||||||
defaultBurstObservatory: {
|
defaultBurstObservatory: {
|
||||||
subjectSelector: [],
|
subjectSelector: [],
|
||||||
pingConfig: {
|
pingConfig: {
|
||||||
destination: "http://www.google.com/gen_204",
|
destination: "https://www.google.com/generate_204",
|
||||||
interval: "30m",
|
interval: "1m",
|
||||||
connectivity: "http://connectivitycheck.platform.hicloud.com/generate_204",
|
connectivity: "http://connectivitycheck.platform.hicloud.com/generate_204",
|
||||||
timeout: "10s",
|
timeout: "5s",
|
||||||
sampling: 2
|
sampling: 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -375,12 +402,17 @@
|
|||||||
this.oldXraySetting = xs;
|
this.oldXraySetting = xs;
|
||||||
this.xraySetting = xs;
|
this.xraySetting = xs;
|
||||||
this.inboundTags = result.inboundTags;
|
this.inboundTags = result.inboundTags;
|
||||||
|
this.outboundTestUrl = result.outboundTestUrl || 'https://www.google.com/generate_204';
|
||||||
|
this.oldOutboundTestUrl = this.outboundTestUrl;
|
||||||
this.saveBtnDisable = true;
|
this.saveBtnDisable = true;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async updateXraySetting() {
|
async updateXraySetting() {
|
||||||
this.loading(true);
|
this.loading(true);
|
||||||
const msg = await HttpUtil.post("/panel/xray/update", { xraySetting: this.xraySetting });
|
const msg = await HttpUtil.post("/panel/xray/update", {
|
||||||
|
xraySetting: this.xraySetting,
|
||||||
|
outboundTestUrl: this.outboundTestUrl || 'https://www.google.com/generate_204'
|
||||||
|
});
|
||||||
this.loading(false);
|
this.loading(false);
|
||||||
if (msg.success) {
|
if (msg.success) {
|
||||||
await this.getXraySetting();
|
await this.getXraySetting();
|
||||||
@@ -595,6 +627,71 @@
|
|||||||
outbounds.splice(0, 0, outbounds.splice(index, 1)[0]);
|
outbounds.splice(0, 0, outbounds.splice(index, 1)[0]);
|
||||||
this.outboundSettings = JSON.stringify(outbounds);
|
this.outboundSettings = JSON.stringify(outbounds);
|
||||||
},
|
},
|
||||||
|
async testOutbound(index) {
|
||||||
|
const outbound = this.templateSettings.outbounds[index];
|
||||||
|
if (!outbound) {
|
||||||
|
Vue.prototype.$message.error('{{ i18n "pages.xray.outbound.testError" }}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outbound.protocol === 'blackhole' || outbound.tag === 'blocked') {
|
||||||
|
Vue.prototype.$message.warning('{{ i18n "pages.xray.outbound.testError" }}: blocked/blackhole outbound');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize test state for this outbound if not exists
|
||||||
|
if (!this.outboundTestStates[index]) {
|
||||||
|
this.$set(this.outboundTestStates, index, {
|
||||||
|
testing: false,
|
||||||
|
result: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set testing state
|
||||||
|
this.$set(this.outboundTestStates[index], 'testing', true);
|
||||||
|
this.$set(this.outboundTestStates[index], 'result', null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const outboundJSON = JSON.stringify(outbound);
|
||||||
|
const allOutboundsJSON = JSON.stringify(this.templateSettings.outbounds || []);
|
||||||
|
|
||||||
|
const msg = await HttpUtil.post("/panel/xray/testOutbound", {
|
||||||
|
outbound: outboundJSON,
|
||||||
|
allOutbounds: allOutboundsJSON
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update test state
|
||||||
|
this.$set(this.outboundTestStates[index], 'testing', false);
|
||||||
|
|
||||||
|
if (msg.success && msg.obj) {
|
||||||
|
const result = msg.obj;
|
||||||
|
this.$set(this.outboundTestStates[index], 'result', result);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
Vue.prototype.$message.success(
|
||||||
|
`{{ i18n "pages.xray.outbound.testSuccess" }}: ${result.delay}ms (${result.statusCode})`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Vue.prototype.$message.error(
|
||||||
|
`{{ i18n "pages.xray.outbound.testFailed" }}: ${result.error || 'Unknown error'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.$set(this.outboundTestStates[index], 'result', {
|
||||||
|
success: false,
|
||||||
|
error: msg.msg || '{{ i18n "pages.xray.outbound.testError" }}'
|
||||||
|
});
|
||||||
|
Vue.prototype.$message.error(msg.msg || '{{ i18n "pages.xray.outbound.testError" }}');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.$set(this.outboundTestStates[index], 'testing', false);
|
||||||
|
this.$set(this.outboundTestStates[index], 'result', {
|
||||||
|
success: false,
|
||||||
|
error: error.message || '{{ i18n "pages.xray.outbound.testError" }}'
|
||||||
|
});
|
||||||
|
Vue.prototype.$message.error('{{ i18n "pages.xray.outbound.testError" }}: ' + error.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
addReverse() {
|
addReverse() {
|
||||||
reverseModal.show({
|
reverseModal.show({
|
||||||
title: '{{ i18n "pages.xray.outbound.addReverse"}}',
|
title: '{{ i18n "pages.xray.outbound.addReverse"}}',
|
||||||
@@ -841,6 +938,15 @@
|
|||||||
if (newTemplateSettings.routing.balancers.length === 0) {
|
if (newTemplateSettings.routing.balancers.length === 0) {
|
||||||
delete newTemplateSettings.routing.balancers;
|
delete newTemplateSettings.routing.balancers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove orphaned balancer references from routing rules
|
||||||
|
if (newTemplateSettings.routing.rules) {
|
||||||
|
newTemplateSettings.routing.rules.forEach((rule) => {
|
||||||
|
if (rule.balancerTag && rule.balancerTag === removedBalancer.tag) {
|
||||||
|
delete rule.balancerTag;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
this.templateSettings = newTemplateSettings;
|
this.templateSettings = newTemplateSettings;
|
||||||
this.updateObservatorySelectors();
|
this.updateObservatorySelectors();
|
||||||
this.obsSettings = '';
|
this.obsSettings = '';
|
||||||
@@ -959,6 +1065,35 @@
|
|||||||
},
|
},
|
||||||
showWarp() {
|
showWarp() {
|
||||||
warpModal.show();
|
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 = msg.obj.geoip ?? [];
|
||||||
|
const geosite = msg.obj.geosite ?? [];
|
||||||
|
const geoSuffix = this.customGeoAliasLabelSuffix || '';
|
||||||
|
geoip.forEach((x) => {
|
||||||
|
this.settingsData.IPsOptions.push({
|
||||||
|
label: x.alias + geoSuffix,
|
||||||
|
value: x.extExample,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
geosite.forEach((x) => {
|
||||||
|
const opt = { label: x.alias + geoSuffix, value: x.extExample };
|
||||||
|
this.settingsData.DomainsOptions.push(opt);
|
||||||
|
this.settingsData.BlockDomainsOptions.push(opt);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load custom geo aliases:', e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
@@ -966,6 +1101,7 @@
|
|||||||
this.showAlert = true;
|
this.showAlert = true;
|
||||||
}
|
}
|
||||||
await this.getXraySetting();
|
await this.getXraySetting();
|
||||||
|
await this.loadCustomGeoAliases();
|
||||||
await this.getXrayResult();
|
await this.getXrayResult();
|
||||||
await this.getOutboundsTraffic();
|
await this.getOutboundsTraffic();
|
||||||
|
|
||||||
@@ -977,11 +1113,19 @@
|
|||||||
this.$forceUpdate();
|
this.$forceUpdate();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle invalidate signals (sent when payload is too large for WebSocket,
|
||||||
|
// or when traffic job notifies about data changes)
|
||||||
|
window.wsClient.on('invalidate', (payload) => {
|
||||||
|
if (payload && payload.type === 'outbounds') {
|
||||||
|
this.refreshOutboundTraffic();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
await PromiseUtil.sleep(800);
|
await PromiseUtil.sleep(800);
|
||||||
this.saveBtnDisable = this.oldXraySetting === this.xraySetting;
|
this.saveBtnDisable = this.oldXraySetting === this.xraySetting && this.oldOutboundTestUrl === this.outboundTestUrl;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -1299,6 +1443,19 @@
|
|||||||
this.templateRuleSetter({ outboundTag: "warp", property: "domain", data: newValue });
|
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: {
|
torrentSettings: {
|
||||||
get: function () {
|
get: function () {
|
||||||
return ArrayUtils.doAllItemsExist(this.settingsData.protocols.bittorrent, this.blockedProtocols);
|
return ArrayUtils.doAllItemsExist(this.settingsData.protocols.bittorrent, this.blockedProtocols);
|
||||||
@@ -1316,6 +1473,11 @@
|
|||||||
return this.templateSettings ? this.templateSettings.outbounds.findIndex((o) => o.tag == "warp") >= 0 : false;
|
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: {
|
enableDNS: {
|
||||||
get: function () {
|
get: function () {
|
||||||
return this.templateSettings ? this.templateSettings.dns != null : false;
|
return this.templateSettings ? this.templateSettings.dns != null : false;
|
||||||
|
|||||||
+312
-39
@@ -3,6 +3,7 @@ package job
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
@@ -18,6 +19,12 @@ import (
|
|||||||
"github.com/mhsanaei/3x-ui/v2/xray"
|
"github.com/mhsanaei/3x-ui/v2/xray"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// IPWithTimestamp tracks an IP address with its last seen timestamp
|
||||||
|
type IPWithTimestamp struct {
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
// CheckClientIpJob monitors client IP addresses from access logs and manages IP blocking based on configured limits.
|
// CheckClientIpJob monitors client IP addresses from access logs and manages IP blocking based on configured limits.
|
||||||
type CheckClientIpJob struct {
|
type CheckClientIpJob struct {
|
||||||
lastClear int64
|
lastClear int64
|
||||||
@@ -26,6 +33,27 @@ type CheckClientIpJob struct {
|
|||||||
|
|
||||||
var job *CheckClientIpJob
|
var job *CheckClientIpJob
|
||||||
|
|
||||||
|
const defaultXrayAPIPort = 62789
|
||||||
|
|
||||||
|
// ipStaleAfterSeconds controls how long a client IP kept in the
|
||||||
|
// per-client tracking table (model.InboundClientIps.Ips) is considered
|
||||||
|
// still "active" before it's evicted during the next scan.
|
||||||
|
//
|
||||||
|
// Without this eviction, an IP that connected once and then went away
|
||||||
|
// keeps sitting in the table with its old timestamp. Because the
|
||||||
|
// excess-IP selector sorts ascending ("oldest wins, newest loses") to
|
||||||
|
// protect the original/current connections, that stale entry keeps
|
||||||
|
// occupying a slot and the IP that is *actually* currently using the
|
||||||
|
// config gets classified as "new excess" and banned by fail2ban on
|
||||||
|
// every single run — producing the continuous ban loop from #4077.
|
||||||
|
//
|
||||||
|
// 30 minutes is chosen so an actively-streaming client (where xray
|
||||||
|
// emits a fresh `accepted` log line whenever it opens a new TCP) will
|
||||||
|
// always refresh its timestamp well within the window, but a client
|
||||||
|
// that has really stopped using the config will drop out of the table
|
||||||
|
// in a bounded time and free its slot.
|
||||||
|
const ipStaleAfterSeconds = int64(30 * 60)
|
||||||
|
|
||||||
// NewCheckClientIpJob creates a new client IP monitoring job instance.
|
// NewCheckClientIpJob creates a new client IP monitoring job instance.
|
||||||
func NewCheckClientIpJob() *CheckClientIpJob {
|
func NewCheckClientIpJob() *CheckClientIpJob {
|
||||||
job = new(CheckClientIpJob)
|
job = new(CheckClientIpJob)
|
||||||
@@ -119,12 +147,14 @@ func (j *CheckClientIpJob) processLogFile() bool {
|
|||||||
|
|
||||||
ipRegex := regexp.MustCompile(`from (?:tcp:|udp:)?\[?([0-9a-fA-F\.:]+)\]?:\d+ accepted`)
|
ipRegex := regexp.MustCompile(`from (?:tcp:|udp:)?\[?([0-9a-fA-F\.:]+)\]?:\d+ accepted`)
|
||||||
emailRegex := regexp.MustCompile(`email: (.+)$`)
|
emailRegex := regexp.MustCompile(`email: (.+)$`)
|
||||||
|
timestampRegex := regexp.MustCompile(`^(\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2})`)
|
||||||
|
|
||||||
accessLogPath, _ := xray.GetAccessLogPath()
|
accessLogPath, _ := xray.GetAccessLogPath()
|
||||||
file, _ := os.Open(accessLogPath)
|
file, _ := os.Open(accessLogPath)
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
inboundClientIps := make(map[string]map[string]struct{}, 100)
|
// Track IPs with their last seen timestamp
|
||||||
|
inboundClientIps := make(map[string]map[string]int64, 100)
|
||||||
|
|
||||||
scanner := bufio.NewScanner(file)
|
scanner := bufio.NewScanner(file)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
@@ -147,33 +177,106 @@ func (j *CheckClientIpJob) processLogFile() bool {
|
|||||||
}
|
}
|
||||||
email := emailMatches[1]
|
email := emailMatches[1]
|
||||||
|
|
||||||
|
// Extract timestamp from log line
|
||||||
|
var timestamp int64
|
||||||
|
timestampMatches := timestampRegex.FindStringSubmatch(line)
|
||||||
|
if len(timestampMatches) >= 2 {
|
||||||
|
t, err := time.Parse("2006/01/02 15:04:05", timestampMatches[1])
|
||||||
|
if err == nil {
|
||||||
|
timestamp = t.Unix()
|
||||||
|
} else {
|
||||||
|
timestamp = time.Now().Unix()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
timestamp = time.Now().Unix()
|
||||||
|
}
|
||||||
|
|
||||||
if _, exists := inboundClientIps[email]; !exists {
|
if _, exists := inboundClientIps[email]; !exists {
|
||||||
inboundClientIps[email] = make(map[string]struct{})
|
inboundClientIps[email] = make(map[string]int64)
|
||||||
|
}
|
||||||
|
// Update timestamp - keep the latest
|
||||||
|
if existingTime, ok := inboundClientIps[email][ip]; !ok || timestamp > existingTime {
|
||||||
|
inboundClientIps[email][ip] = timestamp
|
||||||
}
|
}
|
||||||
inboundClientIps[email][ip] = struct{}{}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
shouldCleanLog := false
|
shouldCleanLog := false
|
||||||
for email, uniqueIps := range inboundClientIps {
|
for email, ipTimestamps := range inboundClientIps {
|
||||||
|
|
||||||
ips := make([]string, 0, len(uniqueIps))
|
// Convert to IPWithTimestamp slice
|
||||||
for ip := range uniqueIps {
|
ipsWithTime := make([]IPWithTimestamp, 0, len(ipTimestamps))
|
||||||
ips = append(ips, ip)
|
for ip, timestamp := range ipTimestamps {
|
||||||
|
ipsWithTime = append(ipsWithTime, IPWithTimestamp{IP: ip, Timestamp: timestamp})
|
||||||
}
|
}
|
||||||
sort.Strings(ips)
|
|
||||||
|
|
||||||
clientIpsRecord, err := j.getInboundClientIps(email)
|
clientIpsRecord, err := j.getInboundClientIps(email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
j.addInboundClientIps(email, ips)
|
j.addInboundClientIps(email, ipsWithTime)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
shouldCleanLog = j.updateInboundClientIps(clientIpsRecord, email, ips) || shouldCleanLog
|
shouldCleanLog = j.updateInboundClientIps(clientIpsRecord, email, ipsWithTime) || shouldCleanLog
|
||||||
}
|
}
|
||||||
|
|
||||||
return shouldCleanLog
|
return shouldCleanLog
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mergeClientIps combines the persisted (old) and freshly observed (new)
|
||||||
|
// IP-with-timestamp lists for a single client into a map. An entry is
|
||||||
|
// dropped if its last-seen timestamp is older than staleCutoff.
|
||||||
|
//
|
||||||
|
// Extracted as a helper so updateInboundClientIps can stay DB-oriented
|
||||||
|
// and the merge policy can be exercised by a unit test.
|
||||||
|
func mergeClientIps(old, new []IPWithTimestamp, staleCutoff int64) map[string]int64 {
|
||||||
|
ipMap := make(map[string]int64, len(old)+len(new))
|
||||||
|
for _, ipTime := range old {
|
||||||
|
if ipTime.Timestamp < staleCutoff {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ipMap[ipTime.IP] = ipTime.Timestamp
|
||||||
|
}
|
||||||
|
for _, ipTime := range new {
|
||||||
|
if ipTime.Timestamp < staleCutoff {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if existingTime, ok := ipMap[ipTime.IP]; !ok || ipTime.Timestamp > existingTime {
|
||||||
|
ipMap[ipTime.IP] = ipTime.Timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ipMap
|
||||||
|
}
|
||||||
|
|
||||||
|
// partitionLiveIps splits the merged ip map into live (seen in the
|
||||||
|
// current scan) and historical (only in the db blob, still inside the
|
||||||
|
// staleness window).
|
||||||
|
//
|
||||||
|
// only live ips count toward the per-client limit. historical ones stay
|
||||||
|
// in the db so the panel keeps showing them, but they must not take a
|
||||||
|
// protected slot. the 30min cutoff alone isn't tight enough: an ip that
|
||||||
|
// stopped connecting a few minutes ago still looks fresh to
|
||||||
|
// mergeClientIps, and since the over-limit picker sorts ascending and
|
||||||
|
// keeps the oldest, those idle entries used to win the slot while the
|
||||||
|
// ip actually connecting got classified as excess and sent to fail2ban
|
||||||
|
// every tick. see #4077 / #4091.
|
||||||
|
//
|
||||||
|
// live is sorted ascending so the "protect original, ban newcomer"
|
||||||
|
// rule still holds when several ips are really connecting at once.
|
||||||
|
func partitionLiveIps(ipMap map[string]int64, observedThisScan map[string]bool) (live, historical []IPWithTimestamp) {
|
||||||
|
live = make([]IPWithTimestamp, 0, len(observedThisScan))
|
||||||
|
historical = make([]IPWithTimestamp, 0, len(ipMap))
|
||||||
|
for ip, ts := range ipMap {
|
||||||
|
entry := IPWithTimestamp{IP: ip, Timestamp: ts}
|
||||||
|
if observedThisScan[ip] {
|
||||||
|
live = append(live, entry)
|
||||||
|
} else {
|
||||||
|
historical = append(historical, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(live, func(i, j int) bool { return live[i].Timestamp < live[j].Timestamp })
|
||||||
|
sort.Slice(historical, func(i, j int) bool { return historical[i].Timestamp < historical[j].Timestamp })
|
||||||
|
return live, historical
|
||||||
|
}
|
||||||
|
|
||||||
func (j *CheckClientIpJob) checkFail2BanInstalled() bool {
|
func (j *CheckClientIpJob) checkFail2BanInstalled() bool {
|
||||||
cmd := "fail2ban-client"
|
cmd := "fail2ban-client"
|
||||||
args := []string{"-h"}
|
args := []string{"-h"}
|
||||||
@@ -213,9 +316,9 @@ func (j *CheckClientIpJob) getInboundClientIps(clientEmail string) (*model.Inbou
|
|||||||
return InboundClientIps, nil
|
return InboundClientIps, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *CheckClientIpJob) addInboundClientIps(clientEmail string, ips []string) error {
|
func (j *CheckClientIpJob) addInboundClientIps(clientEmail string, ipsWithTime []IPWithTimestamp) error {
|
||||||
inboundClientIps := &model.InboundClientIps{}
|
inboundClientIps := &model.InboundClientIps{}
|
||||||
jsonIps, err := json.Marshal(ips)
|
jsonIps, err := json.Marshal(ipsWithTime)
|
||||||
j.checkError(err)
|
j.checkError(err)
|
||||||
|
|
||||||
inboundClientIps.ClientEmail = clientEmail
|
inboundClientIps.ClientEmail = clientEmail
|
||||||
@@ -239,16 +342,8 @@ func (j *CheckClientIpJob) addInboundClientIps(clientEmail string, ips []string)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.InboundClientIps, clientEmail string, ips []string) bool {
|
func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.InboundClientIps, clientEmail string, newIpsWithTime []IPWithTimestamp) bool {
|
||||||
jsonIps, err := json.Marshal(ips)
|
// Get the inbound configuration
|
||||||
if err != nil {
|
|
||||||
logger.Error("failed to marshal IPs to JSON:", err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
inboundClientIps.ClientEmail = clientEmail
|
|
||||||
inboundClientIps.Ips = string(jsonIps)
|
|
||||||
|
|
||||||
inbound, err := j.getInboundByEmail(clientEmail)
|
inbound, err := j.getInboundByEmail(clientEmail)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("failed to fetch inbound settings for email %s: %s", clientEmail, err)
|
logger.Errorf("failed to fetch inbound settings for email %s: %s", clientEmail, err)
|
||||||
@@ -263,9 +358,49 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
|
|||||||
settings := map[string][]model.Client{}
|
settings := map[string][]model.Client{}
|
||||||
json.Unmarshal([]byte(inbound.Settings), &settings)
|
json.Unmarshal([]byte(inbound.Settings), &settings)
|
||||||
clients := settings["clients"]
|
clients := settings["clients"]
|
||||||
|
|
||||||
|
// Find the client's IP limit
|
||||||
|
var limitIp int
|
||||||
|
var clientFound bool
|
||||||
|
for _, client := range clients {
|
||||||
|
if client.Email == clientEmail {
|
||||||
|
limitIp = client.LimitIP
|
||||||
|
clientFound = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !clientFound || limitIp <= 0 || !inbound.Enable {
|
||||||
|
// No limit or inbound disabled, just update and return
|
||||||
|
jsonIps, _ := json.Marshal(newIpsWithTime)
|
||||||
|
inboundClientIps.Ips = string(jsonIps)
|
||||||
|
db := database.GetDB()
|
||||||
|
db.Save(inboundClientIps)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse old IPs from database
|
||||||
|
var oldIpsWithTime []IPWithTimestamp
|
||||||
|
if inboundClientIps.Ips != "" {
|
||||||
|
json.Unmarshal([]byte(inboundClientIps.Ips), &oldIpsWithTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge old and new IPs, evicting entries that haven't been
|
||||||
|
// re-observed in a while. See mergeClientIps / #4077 for why.
|
||||||
|
ipMap := mergeClientIps(oldIpsWithTime, newIpsWithTime, time.Now().Unix()-ipStaleAfterSeconds)
|
||||||
|
|
||||||
|
// only ips seen in this scan count toward the limit. see
|
||||||
|
// partitionLiveIps.
|
||||||
|
observedThisScan := make(map[string]bool, len(newIpsWithTime))
|
||||||
|
for _, ipTime := range newIpsWithTime {
|
||||||
|
observedThisScan[ipTime.IP] = true
|
||||||
|
}
|
||||||
|
liveIps, historicalIps := partitionLiveIps(ipMap, observedThisScan)
|
||||||
|
|
||||||
shouldCleanLog := false
|
shouldCleanLog := false
|
||||||
j.disAllowedIps = []string{}
|
j.disAllowedIps = []string{}
|
||||||
|
|
||||||
|
// Open log file
|
||||||
logIpFile, err := os.OpenFile(xray.GetIPLimitLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
logIpFile, err := os.OpenFile(xray.GetIPLimitLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("failed to open IP limit log file: %s", err)
|
logger.Errorf("failed to open IP limit log file: %s", err)
|
||||||
@@ -275,29 +410,39 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
|
|||||||
log.SetOutput(logIpFile)
|
log.SetOutput(logIpFile)
|
||||||
log.SetFlags(log.LstdFlags)
|
log.SetFlags(log.LstdFlags)
|
||||||
|
|
||||||
for _, client := range clients {
|
// historical db-only ips are excluded from this count on purpose.
|
||||||
if client.Email == clientEmail {
|
var keptLive []IPWithTimestamp
|
||||||
limitIp := client.LimitIP
|
if len(liveIps) > limitIp {
|
||||||
|
|
||||||
if limitIp > 0 && inbound.Enable {
|
|
||||||
shouldCleanLog = true
|
shouldCleanLog = true
|
||||||
|
|
||||||
if limitIp < len(ips) {
|
// protect the oldest live ip, ban newcomers.
|
||||||
j.disAllowedIps = append(j.disAllowedIps, ips[limitIp:]...)
|
keptLive = liveIps[:limitIp]
|
||||||
for i := limitIp; i < len(ips); i++ {
|
bannedLive := liveIps[limitIp:]
|
||||||
log.Printf("[LIMIT_IP] Email = %s || SRC = %s", clientEmail, ips[i])
|
|
||||||
}
|
// log format is load-bearing: x-ui.sh create_iplimit_jails builds
|
||||||
}
|
// filter.d/3x-ipl.conf with
|
||||||
}
|
// failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnecting OLD IP\s*=\s*<ADDR>\s*\|\|\s*Timestamp\s*=\s*\d+
|
||||||
}
|
// don't change the wording.
|
||||||
|
for _, ipTime := range bannedLive {
|
||||||
|
j.disAllowedIps = append(j.disAllowedIps, ipTime.IP)
|
||||||
|
log.Printf("[LIMIT_IP] Email = %s || Disconnecting OLD IP = %s || Timestamp = %d", clientEmail, ipTime.IP, ipTime.Timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Strings(j.disAllowedIps)
|
// force xray to drop existing connections from banned ips
|
||||||
|
j.disconnectClientTemporarily(inbound, clientEmail, clients)
|
||||||
if len(j.disAllowedIps) > 0 {
|
} else {
|
||||||
logger.Debug("disAllowedIps:", j.disAllowedIps)
|
keptLive = liveIps
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// keep kept-live + historical in the blob so the panel keeps showing
|
||||||
|
// recently seen ips. banned live ips are already in the fail2ban log
|
||||||
|
// and will reappear in the next scan if they reconnect.
|
||||||
|
dbIps := make([]IPWithTimestamp, 0, len(keptLive)+len(historicalIps))
|
||||||
|
dbIps = append(dbIps, keptLive...)
|
||||||
|
dbIps = append(dbIps, historicalIps...)
|
||||||
|
jsonIps, _ := json.Marshal(dbIps)
|
||||||
|
inboundClientIps.Ips = string(jsonIps)
|
||||||
|
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
err = db.Save(inboundClientIps).Error
|
err = db.Save(inboundClientIps).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -305,9 +450,137 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(j.disAllowedIps) > 0 {
|
||||||
|
logger.Infof("[LIMIT_IP] Client %s: Kept %d live IPs, queued %d new IPs for fail2ban", clientEmail, len(keptLive), len(j.disAllowedIps))
|
||||||
|
}
|
||||||
|
|
||||||
return shouldCleanLog
|
return shouldCleanLog
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
apiPort := j.resolveXrayAPIPort()
|
||||||
|
|
||||||
|
err := xrayAPI.Init(apiPort)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warningf("[LIMIT_IP] Failed to init Xray API for disconnection: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer xrayAPI.Close()
|
||||||
|
|
||||||
|
// Find the client config
|
||||||
|
var clientConfig map[string]any
|
||||||
|
for _, client := range clients {
|
||||||
|
if client.Email == clientEmail {
|
||||||
|
// Convert client to map for API
|
||||||
|
clientBytes, _ := json.Marshal(client)
|
||||||
|
json.Unmarshal(clientBytes, &clientConfig)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if clientConfig == nil {
|
||||||
|
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 {
|
||||||
|
logger.Warningf("[LIMIT_IP] Failed to remove user %s: %v", clientEmail, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait a moment for disconnection to take effect
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
// Re-add user to allow new connections
|
||||||
|
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) {
|
func (j *CheckClientIpJob) getInboundByEmail(clientEmail string) (*model.Inbound, error) {
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
inbound := &model.Inbound{}
|
inbound := &model.Inbound{}
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
package job
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/database"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||||
|
xuilogger "github.com/mhsanaei/3x-ui/v2/logger"
|
||||||
|
"github.com/op/go-logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 3x-ui logger must be initialised once before any code path that can
|
||||||
|
// log a warning. otherwise log.Warningf panics on a nil logger.
|
||||||
|
var loggerInitOnce sync.Once
|
||||||
|
|
||||||
|
// setupIntegrationDB wires a temp sqlite db and log folder so
|
||||||
|
// updateInboundClientIps can run end to end. closes the db before
|
||||||
|
// TempDir cleanup so windows doesn't complain about the file being in
|
||||||
|
// use.
|
||||||
|
func setupIntegrationDB(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
loggerInitOnce.Do(func() {
|
||||||
|
xuilogger.InitLogger(logging.ERROR)
|
||||||
|
})
|
||||||
|
|
||||||
|
dbDir := t.TempDir()
|
||||||
|
logDir := t.TempDir()
|
||||||
|
|
||||||
|
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||||
|
t.Setenv("XUI_LOG_FOLDER", logDir)
|
||||||
|
|
||||||
|
// updateInboundClientIps calls log.SetOutput on the package global,
|
||||||
|
// which would leak to other tests in the same binary.
|
||||||
|
origLogWriter := log.Writer()
|
||||||
|
origLogFlags := log.Flags()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
log.SetOutput(origLogWriter)
|
||||||
|
log.SetFlags(origLogFlags)
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := database.InitDB(filepath.Join(dbDir, "3x-ui.db")); err != nil {
|
||||||
|
t.Fatalf("database.InitDB failed: %v", err)
|
||||||
|
}
|
||||||
|
// LIFO cleanup order: this runs before t.TempDir's own cleanup.
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := database.CloseDB(); err != nil {
|
||||||
|
t.Logf("database.CloseDB warning: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// seed an inbound whose settings json has a single client with the
|
||||||
|
// given email and ip limit.
|
||||||
|
func seedInboundWithClient(t *testing.T, tag, email string, limitIp int) {
|
||||||
|
t.Helper()
|
||||||
|
settings := map[string]any{
|
||||||
|
"clients": []map[string]any{
|
||||||
|
{
|
||||||
|
"email": email,
|
||||||
|
"limitIp": limitIp,
|
||||||
|
"enable": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
settingsJSON, err := json.Marshal(settings)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal settings: %v", err)
|
||||||
|
}
|
||||||
|
inbound := &model.Inbound{
|
||||||
|
Tag: tag,
|
||||||
|
Enable: true,
|
||||||
|
Protocol: model.VLESS,
|
||||||
|
Port: 4321,
|
||||||
|
Settings: string(settingsJSON),
|
||||||
|
}
|
||||||
|
if err := database.GetDB().Create(inbound).Error; err != nil {
|
||||||
|
t.Fatalf("seed inbound: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seed an InboundClientIps row with the given blob.
|
||||||
|
func seedClientIps(t *testing.T, email string, ips []IPWithTimestamp) *model.InboundClientIps {
|
||||||
|
t.Helper()
|
||||||
|
blob, err := json.Marshal(ips)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal ips: %v", err)
|
||||||
|
}
|
||||||
|
row := &model.InboundClientIps{
|
||||||
|
ClientEmail: email,
|
||||||
|
Ips: string(blob),
|
||||||
|
}
|
||||||
|
if err := database.GetDB().Create(row).Error; err != nil {
|
||||||
|
t.Fatalf("seed InboundClientIps: %v", err)
|
||||||
|
}
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
// read the persisted blob and parse it back.
|
||||||
|
func readClientIps(t *testing.T, email string) []IPWithTimestamp {
|
||||||
|
t.Helper()
|
||||||
|
row := &model.InboundClientIps{}
|
||||||
|
if err := database.GetDB().Where("client_email = ?", email).First(row).Error; err != nil {
|
||||||
|
t.Fatalf("read InboundClientIps for %s: %v", email, err)
|
||||||
|
}
|
||||||
|
if row.Ips == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []IPWithTimestamp
|
||||||
|
if err := json.Unmarshal([]byte(row.Ips), &out); err != nil {
|
||||||
|
t.Fatalf("unmarshal Ips blob %q: %v", row.Ips, err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// make a lookup map so asserts don't depend on slice order.
|
||||||
|
func ipSet(entries []IPWithTimestamp) map[string]int64 {
|
||||||
|
out := make(map[string]int64, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
out[e.IP] = e.Timestamp
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// #4091 repro: client has limit=3, db still holds 3 idle ips from a
|
||||||
|
// few minutes ago, only one live ip is actually connecting. pre-fix:
|
||||||
|
// live ip got banned every tick and never appeared in the panel.
|
||||||
|
// post-fix: no ban, live ip persisted, historical ips still visible.
|
||||||
|
func TestUpdateInboundClientIps_LiveIpNotBannedByStillFreshHistoricals(t *testing.T) {
|
||||||
|
setupIntegrationDB(t)
|
||||||
|
|
||||||
|
const email = "pr4091-repro"
|
||||||
|
seedInboundWithClient(t, "inbound-pr4091", email, 3)
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
// idle but still within the 30min staleness window.
|
||||||
|
row := seedClientIps(t, email, []IPWithTimestamp{
|
||||||
|
{IP: "10.0.0.1", Timestamp: now - 20*60},
|
||||||
|
{IP: "10.0.0.2", Timestamp: now - 15*60},
|
||||||
|
{IP: "10.0.0.3", Timestamp: now - 10*60},
|
||||||
|
})
|
||||||
|
|
||||||
|
j := NewCheckClientIpJob()
|
||||||
|
// the one that's actually connecting (user's 128.71.x.x).
|
||||||
|
live := []IPWithTimestamp{
|
||||||
|
{IP: "128.71.1.1", Timestamp: now},
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldCleanLog := j.updateInboundClientIps(row, email, live)
|
||||||
|
|
||||||
|
if shouldCleanLog {
|
||||||
|
t.Fatalf("shouldCleanLog must be false, nothing should have been banned with 1 live ip under limit 3")
|
||||||
|
}
|
||||||
|
if len(j.disAllowedIps) != 0 {
|
||||||
|
t.Fatalf("disAllowedIps must be empty, got %v", j.disAllowedIps)
|
||||||
|
}
|
||||||
|
|
||||||
|
persisted := ipSet(readClientIps(t, email))
|
||||||
|
for _, want := range []string{"128.71.1.1", "10.0.0.1", "10.0.0.2", "10.0.0.3"} {
|
||||||
|
if _, ok := persisted[want]; !ok {
|
||||||
|
t.Errorf("expected %s to be persisted in inbound_client_ips.ips; got %v", want, persisted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := persisted["128.71.1.1"]; got != now {
|
||||||
|
t.Errorf("live ip timestamp should match the scan timestamp %d, got %d", now, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3xipl.log must not contain a ban line.
|
||||||
|
if info, err := os.Stat(readIpLimitLogPath()); err == nil && info.Size() > 0 {
|
||||||
|
body, _ := os.ReadFile(readIpLimitLogPath())
|
||||||
|
t.Fatalf("3xipl.log should be empty when no ips are banned, got:\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// opposite invariant: when several ips are actually live and exceed
|
||||||
|
// the limit, the newcomer still gets banned.
|
||||||
|
func TestUpdateInboundClientIps_ExcessLiveIpIsStillBanned(t *testing.T) {
|
||||||
|
setupIntegrationDB(t)
|
||||||
|
|
||||||
|
const email = "pr4091-abuse"
|
||||||
|
seedInboundWithClient(t, "inbound-pr4091-abuse", email, 1)
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
row := seedClientIps(t, email, []IPWithTimestamp{
|
||||||
|
{IP: "10.1.0.1", Timestamp: now - 60}, // original connection
|
||||||
|
})
|
||||||
|
|
||||||
|
j := NewCheckClientIpJob()
|
||||||
|
// both live, limit=1. use distinct timestamps so sort-by-timestamp
|
||||||
|
// is deterministic: 10.1.0.1 is the original (older), 192.0.2.9
|
||||||
|
// joined later and must get banned.
|
||||||
|
live := []IPWithTimestamp{
|
||||||
|
{IP: "10.1.0.1", Timestamp: now - 5},
|
||||||
|
{IP: "192.0.2.9", Timestamp: now},
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldCleanLog := j.updateInboundClientIps(row, email, live)
|
||||||
|
|
||||||
|
if !shouldCleanLog {
|
||||||
|
t.Fatalf("shouldCleanLog must be true when the live set exceeds the limit")
|
||||||
|
}
|
||||||
|
if len(j.disAllowedIps) != 1 || j.disAllowedIps[0] != "192.0.2.9" {
|
||||||
|
t.Fatalf("expected 192.0.2.9 to be banned; disAllowedIps = %v", j.disAllowedIps)
|
||||||
|
}
|
||||||
|
|
||||||
|
persisted := ipSet(readClientIps(t, email))
|
||||||
|
if _, ok := persisted["10.1.0.1"]; !ok {
|
||||||
|
t.Errorf("original IP 10.1.0.1 must still be persisted; got %v", persisted)
|
||||||
|
}
|
||||||
|
if _, ok := persisted["192.0.2.9"]; ok {
|
||||||
|
t.Errorf("banned IP 192.0.2.9 must NOT be persisted; got %v", persisted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3xipl.log must contain the ban line in the exact fail2ban format.
|
||||||
|
body, err := os.ReadFile(readIpLimitLogPath())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read 3xipl.log: %v", err)
|
||||||
|
}
|
||||||
|
wantSubstr := "[LIMIT_IP] Email = pr4091-abuse || Disconnecting OLD IP = 192.0.2.9"
|
||||||
|
if !contains(string(body), wantSubstr) {
|
||||||
|
t.Fatalf("3xipl.log missing expected ban line %q\nfull log:\n%s", wantSubstr, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readIpLimitLogPath reads the 3xipl.log path the same way the job
|
||||||
|
// does via xray.GetIPLimitLogPath but without importing xray here
|
||||||
|
// just for the path helper (which would pull a lot more deps into the
|
||||||
|
// test binary). The env-derived log folder is deterministic.
|
||||||
|
func readIpLimitLogPath() string {
|
||||||
|
folder := os.Getenv("XUI_LOG_FOLDER")
|
||||||
|
if folder == "" {
|
||||||
|
folder = filepath.Join(".", "log")
|
||||||
|
}
|
||||||
|
return filepath.Join(folder, "3xipl.log")
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(haystack, needle string) bool {
|
||||||
|
for i := 0; i+len(needle) <= len(haystack); i++ {
|
||||||
|
if haystack[i:i+len(needle)] == needle {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package job
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMergeClientIps_EvictsStaleOldEntries(t *testing.T) {
|
||||||
|
// #4077: after a ban expires, a single IP that reconnects used to get
|
||||||
|
// banned again immediately because a long-disconnected IP stayed in the
|
||||||
|
// DB with an ancient timestamp and kept "protecting" itself against
|
||||||
|
// eviction. Guard against that regression here.
|
||||||
|
old := []IPWithTimestamp{
|
||||||
|
{IP: "1.1.1.1", Timestamp: 100}, // stale — client disconnected long ago
|
||||||
|
{IP: "2.2.2.2", Timestamp: 1900}, // fresh — still connecting
|
||||||
|
}
|
||||||
|
new := []IPWithTimestamp{
|
||||||
|
{IP: "2.2.2.2", Timestamp: 2000}, // same IP, newer log line
|
||||||
|
}
|
||||||
|
|
||||||
|
got := mergeClientIps(old, new, 1000)
|
||||||
|
|
||||||
|
want := map[string]int64{"2.2.2.2": 2000}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("stale 1.1.1.1 should have been dropped\ngot: %v\nwant: %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeClientIps_KeepsFreshOldEntriesUnchanged(t *testing.T) {
|
||||||
|
// Backwards-compat: entries that aren't stale are still carried forward,
|
||||||
|
// so enforcement survives access-log rotation.
|
||||||
|
old := []IPWithTimestamp{
|
||||||
|
{IP: "1.1.1.1", Timestamp: 1500},
|
||||||
|
}
|
||||||
|
got := mergeClientIps(old, nil, 1000)
|
||||||
|
|
||||||
|
want := map[string]int64{"1.1.1.1": 1500}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("fresh old IP should have been retained\ngot: %v\nwant: %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeClientIps_PrefersLaterTimestampForSameIp(t *testing.T) {
|
||||||
|
old := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 1500}}
|
||||||
|
new := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 1700}}
|
||||||
|
|
||||||
|
got := mergeClientIps(old, new, 1000)
|
||||||
|
|
||||||
|
if got["1.1.1.1"] != 1700 {
|
||||||
|
t.Fatalf("expected latest timestamp 1700, got %d", got["1.1.1.1"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeClientIps_DropsStaleNewEntries(t *testing.T) {
|
||||||
|
// A log line with a clock-skewed old timestamp must not resurrect a
|
||||||
|
// stale IP past the cutoff.
|
||||||
|
new := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 500}}
|
||||||
|
got := mergeClientIps(nil, new, 1000)
|
||||||
|
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Fatalf("stale new IP should have been dropped, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeClientIps_NoStaleCutoffStillWorks(t *testing.T) {
|
||||||
|
// Defensive: a zero cutoff (e.g. during very first run on a fresh
|
||||||
|
// install) must not over-evict.
|
||||||
|
old := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 100}}
|
||||||
|
new := []IPWithTimestamp{{IP: "2.2.2.2", Timestamp: 200}}
|
||||||
|
|
||||||
|
got := mergeClientIps(old, new, 0)
|
||||||
|
|
||||||
|
want := map[string]int64{"1.1.1.1": 100, "2.2.2.2": 200}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("zero cutoff should keep everything\ngot: %v\nwant: %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectIps(entries []IPWithTimestamp) []string {
|
||||||
|
out := make([]string, 0, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
out = append(out, e.IP)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPartitionLiveIps_SingleLiveNotStarvedByStillFreshHistoricals(t *testing.T) {
|
||||||
|
// #4091: db holds A, B, C from minutes ago (still in the 30min
|
||||||
|
// window) but they're not connecting anymore. only D is. old code
|
||||||
|
// merged all four, sorted ascending, kept [A,B,C] and banned D
|
||||||
|
// every tick. pin the new rule: only live ips count toward the limit.
|
||||||
|
ipMap := map[string]int64{
|
||||||
|
"A": 1000,
|
||||||
|
"B": 1100,
|
||||||
|
"C": 1200,
|
||||||
|
"D": 2000,
|
||||||
|
}
|
||||||
|
observed := map[string]bool{"D": true}
|
||||||
|
|
||||||
|
live, historical := partitionLiveIps(ipMap, observed)
|
||||||
|
|
||||||
|
if got := collectIps(live); !reflect.DeepEqual(got, []string{"D"}) {
|
||||||
|
t.Fatalf("live set should only contain the ip observed this scan\ngot: %v\nwant: [D]", got)
|
||||||
|
}
|
||||||
|
if got := collectIps(historical); !reflect.DeepEqual(got, []string{"A", "B", "C"}) {
|
||||||
|
t.Fatalf("historical set should contain db-only ips in ascending order\ngot: %v\nwant: [A B C]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPartitionLiveIps_ConcurrentLiveIpsStillBanNewcomers(t *testing.T) {
|
||||||
|
// keep the "protect original, ban newcomer" policy when several ips
|
||||||
|
// are really live. with limit=1, A must stay and B must be banned.
|
||||||
|
ipMap := map[string]int64{
|
||||||
|
"A": 5000,
|
||||||
|
"B": 5500,
|
||||||
|
}
|
||||||
|
observed := map[string]bool{"A": true, "B": true}
|
||||||
|
|
||||||
|
live, historical := partitionLiveIps(ipMap, observed)
|
||||||
|
|
||||||
|
if got := collectIps(live); !reflect.DeepEqual(got, []string{"A", "B"}) {
|
||||||
|
t.Fatalf("both live ips should be in the live set, ascending\ngot: %v\nwant: [A B]", got)
|
||||||
|
}
|
||||||
|
if len(historical) != 0 {
|
||||||
|
t.Fatalf("no historical ips expected, got %v", historical)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPartitionLiveIps_EmptyScanLeavesDbIntact(t *testing.T) {
|
||||||
|
// quiet tick: nothing observed => nothing live. everything merged
|
||||||
|
// is historical. keeps the panel from wiping recent-but-idle ips.
|
||||||
|
ipMap := map[string]int64{
|
||||||
|
"A": 1000,
|
||||||
|
"B": 1100,
|
||||||
|
}
|
||||||
|
observed := map[string]bool{}
|
||||||
|
|
||||||
|
live, historical := partitionLiveIps(ipMap, observed)
|
||||||
|
|
||||||
|
if len(live) != 0 {
|
||||||
|
t.Fatalf("no live ips expected, got %v", live)
|
||||||
|
}
|
||||||
|
if got := collectIps(historical); !reflect.DeepEqual(got, []string{"A", "B"}) {
|
||||||
|
t.Fatalf("all merged entries should flow to historical\ngot: %v\nwant: [A B]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -271,10 +271,7 @@ func (j *LdapSyncJob) deleteClientsNotInLDAP(inboundTag string, ldapEmails map[s
|
|||||||
|
|
||||||
// Delete in batches
|
// Delete in batches
|
||||||
for i := 0; i < len(toDelete); i += batchSize {
|
for i := 0; i < len(toDelete); i += batchSize {
|
||||||
end := i + batchSize
|
end := min(i+batchSize, len(toDelete))
|
||||||
if end > len(toDelete) {
|
|
||||||
end = len(toDelete)
|
|
||||||
}
|
|
||||||
batch := toDelete[i:end]
|
batch := toDelete[i:end]
|
||||||
|
|
||||||
for _, c := range batch {
|
for _, c := range batch {
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ func (j *PeriodicTrafficResetJob) Run() {
|
|||||||
resetCount := 0
|
resetCount := 0
|
||||||
|
|
||||||
for _, inbound := range inbounds {
|
for _, inbound := range inbounds {
|
||||||
resetInboundErr := j.inboundService.ResetAllTraffics()
|
resetInboundErr := j.inboundService.ResetInboundTraffic(inbound.Id)
|
||||||
if resetInboundErr != nil {
|
if resetInboundErr != nil {
|
||||||
logger.Warning("Failed to reset traffic for inbound", inbound.Id, ":", resetInboundErr)
|
logger.Warning("Failed to reset traffic for inbound", inbound.Id, ":", resetInboundErr)
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-13
@@ -50,7 +50,13 @@ func (j *XrayTrafficJob) Run() {
|
|||||||
j.xrayService.SetToNeedRestart()
|
j.xrayService.SetToNeedRestart()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get online clients and last online map for real-time status updates
|
// If no frontend client is connected, skip all WebSocket broadcasting routines,
|
||||||
|
// including expensive DB queries for online clients and JSON marshaling.
|
||||||
|
if !websocket.HasClients() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update online clients list and map
|
||||||
onlineClients := j.inboundService.GetOnlineClients()
|
onlineClients := j.inboundService.GetOnlineClients()
|
||||||
lastOnlineMap, err := j.inboundService.GetClientsLastOnline()
|
lastOnlineMap, err := j.inboundService.GetClientsLastOnline()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -58,8 +64,17 @@ func (j *XrayTrafficJob) Run() {
|
|||||||
lastOnlineMap = make(map[string]int64)
|
lastOnlineMap = make(map[string]int64)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Broadcast traffic update (deltas and online stats) via WebSocket
|
||||||
|
trafficUpdate := map[string]any{
|
||||||
|
"traffics": traffics,
|
||||||
|
"clientTraffics": clientTraffics,
|
||||||
|
"onlineClients": onlineClients,
|
||||||
|
"lastOnlineMap": lastOnlineMap,
|
||||||
|
}
|
||||||
|
websocket.BroadcastTraffic(trafficUpdate)
|
||||||
|
|
||||||
// Fetch updated inbounds from database with accumulated traffic values
|
// Fetch updated inbounds from database with accumulated traffic values
|
||||||
// This ensures frontend receives the actual total traffic, not just delta values
|
// This ensures frontend receives the actual total traffic for real-time UI refresh.
|
||||||
updatedInbounds, err := j.inboundService.GetAllInbounds()
|
updatedInbounds, err := j.inboundService.GetAllInbounds()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warning("get all inbounds for websocket failed:", err)
|
logger.Warning("get all inbounds for websocket failed:", err)
|
||||||
@@ -70,16 +85,8 @@ func (j *XrayTrafficJob) Run() {
|
|||||||
logger.Warning("get all outbounds for websocket failed:", err)
|
logger.Warning("get all outbounds for websocket failed:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Broadcast traffic update via WebSocket with accumulated values from database
|
// The WebSocket hub will automatically check the payload size.
|
||||||
trafficUpdate := map[string]interface{}{
|
// If it exceeds 100MB, it sends a lightweight 'invalidate' signal instead.
|
||||||
"traffics": traffics,
|
|
||||||
"clientTraffics": clientTraffics,
|
|
||||||
"onlineClients": onlineClients,
|
|
||||||
"lastOnlineMap": lastOnlineMap,
|
|
||||||
}
|
|
||||||
websocket.BroadcastTraffic(trafficUpdate)
|
|
||||||
|
|
||||||
// Broadcast full inbounds update for real-time UI refresh
|
|
||||||
if updatedInbounds != nil {
|
if updatedInbounds != nil {
|
||||||
websocket.BroadcastInbounds(updatedInbounds)
|
websocket.BroadcastInbounds(updatedInbounds)
|
||||||
}
|
}
|
||||||
@@ -87,7 +94,6 @@ func (j *XrayTrafficJob) Run() {
|
|||||||
if updatedOutbounds != nil {
|
if updatedOutbounds != nil {
|
||||||
websocket.BroadcastOutbounds(updatedOutbounds)
|
websocket.BroadcastOutbounds(updatedOutbounds)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *XrayTrafficJob) informTrafficToExternalAPI(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) {
|
func (j *XrayTrafficJob) informTrafficToExternalAPI(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) {
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ type SettingService interface {
|
|||||||
|
|
||||||
// InitLocalizer initializes the internationalization system with embedded translation files.
|
// InitLocalizer initializes the internationalization system with embedded translation files.
|
||||||
func InitLocalizer(i18nFS embed.FS, settingService SettingService) error {
|
func InitLocalizer(i18nFS embed.FS, settingService SettingService) error {
|
||||||
// set default bundle to english
|
// set default bundle to English
|
||||||
i18nBundle = i18n.NewBundle(language.MustParse("en-US"))
|
i18nBundle = i18n.NewBundle(language.MustParse("en-US"))
|
||||||
i18nBundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
|
i18nBundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+335
-5
@@ -10,6 +10,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/mhsanaei/3x-ui/v2/database"
|
"github.com/mhsanaei/3x-ui/v2/database"
|
||||||
"github.com/mhsanaei/3x-ui/v2/database/model"
|
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v2/logger"
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||||
@@ -26,6 +27,12 @@ type InboundService struct {
|
|||||||
xrayApi xray.XrayAPI
|
xrayApi xray.XrayAPI
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CopyClientsResult struct {
|
||||||
|
Added []string `json:"added"`
|
||||||
|
Skipped []string `json:"skipped"`
|
||||||
|
Errors []string `json:"errors"`
|
||||||
|
}
|
||||||
|
|
||||||
// GetInbounds retrieves all inbounds for a specific user.
|
// GetInbounds retrieves all inbounds for a specific user.
|
||||||
// Returns a slice of inbound models with their associated client statistics.
|
// Returns a slice of inbound models with their associated client statistics.
|
||||||
func (s *InboundService) GetInbounds(userId int) ([]*model.Inbound, error) {
|
func (s *InboundService) GetInbounds(userId int) ([]*model.Inbound, error) {
|
||||||
@@ -270,6 +277,10 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
|
|||||||
if client.Email == "" {
|
if client.Email == "" {
|
||||||
return inbound, false, common.NewError("empty client ID")
|
return inbound, false, common.NewError("empty client ID")
|
||||||
}
|
}
|
||||||
|
case "hysteria", "hysteria2":
|
||||||
|
if client.Auth == "" {
|
||||||
|
return inbound, false, common.NewError("empty client ID")
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
if client.ID == "" {
|
if client.ID == "" {
|
||||||
return inbound, false, common.NewError("empty client ID")
|
return inbound, false, common.NewError("empty client ID")
|
||||||
@@ -494,7 +505,12 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
|||||||
logger.Debug("Old inbound deleted by api:", tag)
|
logger.Debug("Old inbound deleted by api:", tag)
|
||||||
}
|
}
|
||||||
if inbound.Enable {
|
if inbound.Enable {
|
||||||
inboundJson, err2 := json.MarshalIndent(oldInbound.GenXrayInboundConfig(), "", " ")
|
runtimeInbound, err2 := s.buildRuntimeInboundForAPI(tx, oldInbound)
|
||||||
|
if err2 != nil {
|
||||||
|
logger.Debug("Unable to prepare runtime inbound config:", err2)
|
||||||
|
needRestart = true
|
||||||
|
} else {
|
||||||
|
inboundJson, err2 := json.MarshalIndent(runtimeInbound.GenXrayInboundConfig(), "", " ")
|
||||||
if err2 != nil {
|
if err2 != nil {
|
||||||
logger.Debug("Unable to marshal updated inbound config:", err2)
|
logger.Debug("Unable to marshal updated inbound config:", err2)
|
||||||
needRestart = true
|
needRestart = true
|
||||||
@@ -508,11 +524,71 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
s.xrayApi.Close()
|
s.xrayApi.Close()
|
||||||
|
|
||||||
return inbound, needRestart, tx.Save(oldInbound).Error
|
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 {
|
func (s *InboundService) updateClientTraffics(tx *gorm.DB, oldInbound *model.Inbound, newInbound *model.Inbound) error {
|
||||||
oldClients, err := s.GetClients(oldInbound)
|
oldClients, err := s.GetClients(oldInbound)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -606,6 +682,10 @@ func (s *InboundService) AddInboundClient(data *model.Inbound) (bool, error) {
|
|||||||
if client.Email == "" {
|
if client.Email == "" {
|
||||||
return false, common.NewError("empty client ID")
|
return false, common.NewError("empty client ID")
|
||||||
}
|
}
|
||||||
|
case "hysteria", "hysteria2":
|
||||||
|
if client.Auth == "" {
|
||||||
|
return false, common.NewError("empty client ID")
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
if client.ID == "" {
|
if client.ID == "" {
|
||||||
return false, common.NewError("empty client ID")
|
return false, common.NewError("empty client ID")
|
||||||
@@ -655,6 +735,7 @@ func (s *InboundService) AddInboundClient(data *model.Inbound) (bool, error) {
|
|||||||
err1 := s.xrayApi.AddUser(string(oldInbound.Protocol), oldInbound.Tag, map[string]any{
|
err1 := s.xrayApi.AddUser(string(oldInbound.Protocol), oldInbound.Tag, map[string]any{
|
||||||
"email": client.Email,
|
"email": client.Email,
|
||||||
"id": client.ID,
|
"id": client.ID,
|
||||||
|
"auth": client.Auth,
|
||||||
"security": client.Security,
|
"security": client.Security,
|
||||||
"flow": client.Flow,
|
"flow": client.Flow,
|
||||||
"password": client.Password,
|
"password": client.Password,
|
||||||
@@ -676,6 +757,202 @@ func (s *InboundService) AddInboundClient(data *model.Inbound) (bool, error) {
|
|||||||
return needRestart, tx.Save(oldInbound).Error
|
return needRestart, tx.Save(oldInbound).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *InboundService) getClientPrimaryKey(protocol model.Protocol, client model.Client) string {
|
||||||
|
switch protocol {
|
||||||
|
case model.Trojan:
|
||||||
|
return client.Password
|
||||||
|
case model.Shadowsocks:
|
||||||
|
return client.Email
|
||||||
|
case model.Hysteria:
|
||||||
|
return client.Auth
|
||||||
|
default:
|
||||||
|
return client.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InboundService) writeBackClientSubID(sourceInboundID int, sourceProtocol model.Protocol, client model.Client, subID string) (bool, error) {
|
||||||
|
client.SubID = subID
|
||||||
|
client.UpdatedAt = time.Now().UnixMilli()
|
||||||
|
clientID := s.getClientPrimaryKey(sourceProtocol, client)
|
||||||
|
if clientID == "" {
|
||||||
|
return false, common.NewError("empty client ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsBytes, err := json.Marshal(map[string][]model.Client{
|
||||||
|
"clients": {client},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePayload := &model.Inbound{
|
||||||
|
Id: sourceInboundID,
|
||||||
|
Settings: string(settingsBytes),
|
||||||
|
}
|
||||||
|
return s.UpdateInboundClient(updatePayload, clientID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InboundService) generateRandomCredential(targetProtocol model.Protocol) string {
|
||||||
|
switch targetProtocol {
|
||||||
|
case model.VMESS, model.VLESS:
|
||||||
|
return uuid.NewString()
|
||||||
|
default:
|
||||||
|
return strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InboundService) buildTargetClientFromSource(source model.Client, targetProtocol model.Protocol, email string, flow string) (model.Client, error) {
|
||||||
|
nowTs := time.Now().UnixMilli()
|
||||||
|
target := source
|
||||||
|
target.Email = email
|
||||||
|
target.CreatedAt = nowTs
|
||||||
|
target.UpdatedAt = nowTs
|
||||||
|
|
||||||
|
target.ID = ""
|
||||||
|
target.Password = ""
|
||||||
|
target.Auth = ""
|
||||||
|
target.Flow = ""
|
||||||
|
|
||||||
|
switch targetProtocol {
|
||||||
|
case model.VMESS:
|
||||||
|
target.ID = s.generateRandomCredential(targetProtocol)
|
||||||
|
case model.VLESS:
|
||||||
|
target.ID = s.generateRandomCredential(targetProtocol)
|
||||||
|
if flow == "xtls-rprx-vision" || flow == "xtls-rprx-vision-udp443" {
|
||||||
|
target.Flow = flow
|
||||||
|
}
|
||||||
|
case model.Trojan, model.Shadowsocks:
|
||||||
|
target.Password = s.generateRandomCredential(targetProtocol)
|
||||||
|
case model.Hysteria:
|
||||||
|
target.Auth = s.generateRandomCredential(targetProtocol)
|
||||||
|
default:
|
||||||
|
target.ID = s.generateRandomCredential(targetProtocol)
|
||||||
|
}
|
||||||
|
|
||||||
|
return target, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InboundService) nextAvailableCopiedEmail(originalEmail string, targetID int, occupied map[string]struct{}) string {
|
||||||
|
base := fmt.Sprintf("%s_%d", originalEmail, targetID)
|
||||||
|
candidate := base
|
||||||
|
suffix := 0
|
||||||
|
for {
|
||||||
|
if _, exists := occupied[strings.ToLower(candidate)]; !exists {
|
||||||
|
occupied[strings.ToLower(candidate)] = struct{}{}
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
suffix++
|
||||||
|
candidate = fmt.Sprintf("%s_%d", base, suffix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InboundService) CopyInboundClients(targetInboundID int, sourceInboundID int, clientEmails []string, flow string) (*CopyClientsResult, bool, error) {
|
||||||
|
result := &CopyClientsResult{
|
||||||
|
Added: []string{},
|
||||||
|
Skipped: []string{},
|
||||||
|
Errors: []string{},
|
||||||
|
}
|
||||||
|
if targetInboundID == sourceInboundID {
|
||||||
|
return result, false, common.NewError("source and target inbounds must be different")
|
||||||
|
}
|
||||||
|
|
||||||
|
targetInbound, err := s.GetInbound(targetInboundID)
|
||||||
|
if err != nil {
|
||||||
|
return result, false, err
|
||||||
|
}
|
||||||
|
sourceInbound, err := s.GetInbound(sourceInboundID)
|
||||||
|
if err != nil {
|
||||||
|
return result, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceClients, err := s.GetClients(sourceInbound)
|
||||||
|
if err != nil {
|
||||||
|
return result, false, err
|
||||||
|
}
|
||||||
|
if len(sourceClients) == 0 {
|
||||||
|
return result, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
allowedEmails := map[string]struct{}{}
|
||||||
|
if len(clientEmails) > 0 {
|
||||||
|
for _, email := range clientEmails {
|
||||||
|
allowedEmails[strings.ToLower(strings.TrimSpace(email))] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
occupiedEmails := map[string]struct{}{}
|
||||||
|
allEmails, err := s.getAllEmails()
|
||||||
|
if err != nil {
|
||||||
|
return result, false, err
|
||||||
|
}
|
||||||
|
for _, email := range allEmails {
|
||||||
|
clean := strings.Trim(email, "\"")
|
||||||
|
if clean != "" {
|
||||||
|
occupiedEmails[strings.ToLower(clean)] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
newClients := make([]model.Client, 0)
|
||||||
|
needRestart := false
|
||||||
|
for _, sourceClient := range sourceClients {
|
||||||
|
originalEmail := strings.TrimSpace(sourceClient.Email)
|
||||||
|
if originalEmail == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(allowedEmails) > 0 {
|
||||||
|
if _, ok := allowedEmails[strings.ToLower(originalEmail)]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if sourceClient.SubID == "" {
|
||||||
|
newSubID := uuid.NewString()
|
||||||
|
subNeedRestart, subErr := s.writeBackClientSubID(sourceInbound.Id, sourceInbound.Protocol, sourceClient, newSubID)
|
||||||
|
if subErr != nil {
|
||||||
|
result.Errors = append(result.Errors, fmt.Sprintf("%s: failed to write source subId: %v", originalEmail, subErr))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if subNeedRestart {
|
||||||
|
needRestart = true
|
||||||
|
}
|
||||||
|
sourceClient.SubID = newSubID
|
||||||
|
}
|
||||||
|
|
||||||
|
targetEmail := s.nextAvailableCopiedEmail(originalEmail, targetInboundID, occupiedEmails)
|
||||||
|
targetClient, buildErr := s.buildTargetClientFromSource(sourceClient, targetInbound.Protocol, targetEmail, flow)
|
||||||
|
if buildErr != nil {
|
||||||
|
result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", originalEmail, buildErr))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
newClients = append(newClients, targetClient)
|
||||||
|
result.Added = append(result.Added, targetEmail)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(newClients) == 0 {
|
||||||
|
return result, needRestart, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsPayload, err := json.Marshal(map[string][]model.Client{
|
||||||
|
"clients": newClients,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return result, needRestart, err
|
||||||
|
}
|
||||||
|
|
||||||
|
addNeedRestart, err := s.AddInboundClient(&model.Inbound{
|
||||||
|
Id: targetInboundID,
|
||||||
|
Settings: string(settingsPayload),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return result, needRestart, err
|
||||||
|
}
|
||||||
|
if addNeedRestart {
|
||||||
|
needRestart = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, needRestart, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *InboundService) DelInboundClient(inboundId int, clientId string) (bool, error) {
|
func (s *InboundService) DelInboundClient(inboundId int, clientId string) (bool, error) {
|
||||||
oldInbound, err := s.GetInbound(inboundId)
|
oldInbound, err := s.GetInbound(inboundId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -690,11 +967,13 @@ func (s *InboundService) DelInboundClient(inboundId int, clientId string) (bool,
|
|||||||
|
|
||||||
email := ""
|
email := ""
|
||||||
client_key := "id"
|
client_key := "id"
|
||||||
if oldInbound.Protocol == "trojan" {
|
switch oldInbound.Protocol {
|
||||||
|
case "trojan":
|
||||||
client_key = "password"
|
client_key = "password"
|
||||||
}
|
case "shadowsocks":
|
||||||
if oldInbound.Protocol == "shadowsocks" {
|
|
||||||
client_key = "email"
|
client_key = "email"
|
||||||
|
case "hysteria", "hysteria2":
|
||||||
|
client_key = "auth"
|
||||||
}
|
}
|
||||||
|
|
||||||
interfaceClients := settings["clients"].([]any)
|
interfaceClients := settings["clients"].([]any)
|
||||||
@@ -801,6 +1080,9 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin
|
|||||||
case "shadowsocks":
|
case "shadowsocks":
|
||||||
oldClientId = oldClient.Email
|
oldClientId = oldClient.Email
|
||||||
newClientId = clients[0].Email
|
newClientId = clients[0].Email
|
||||||
|
case "hysteria", "hysteria2":
|
||||||
|
oldClientId = oldClient.Auth
|
||||||
|
newClientId = clients[0].Auth
|
||||||
default:
|
default:
|
||||||
oldClientId = oldClient.ID
|
oldClientId = oldClient.ID
|
||||||
newClientId = clients[0].ID
|
newClientId = clients[0].ID
|
||||||
@@ -921,6 +1203,7 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin
|
|||||||
"id": clients[0].ID,
|
"id": clients[0].ID,
|
||||||
"security": clients[0].Security,
|
"security": clients[0].Security,
|
||||||
"flow": clients[0].Flow,
|
"flow": clients[0].Flow,
|
||||||
|
"auth": clients[0].Auth,
|
||||||
"password": clients[0].Password,
|
"password": clients[0].Password,
|
||||||
"cipher": cipher,
|
"cipher": cipher,
|
||||||
})
|
})
|
||||||
@@ -1813,6 +2096,7 @@ func (s *InboundService) ResetClientTraffic(id int, clientEmail string) (bool, e
|
|||||||
err1 := s.xrayApi.AddUser(string(inbound.Protocol), inbound.Tag, map[string]any{
|
err1 := s.xrayApi.AddUser(string(inbound.Protocol), inbound.Tag, map[string]any{
|
||||||
"email": client.Email,
|
"email": client.Email,
|
||||||
"id": client.ID,
|
"id": client.ID,
|
||||||
|
"auth": client.Auth,
|
||||||
"security": client.Security,
|
"security": client.Security,
|
||||||
"flow": client.Flow,
|
"flow": client.Flow,
|
||||||
"password": client.Password,
|
"password": client.Password,
|
||||||
@@ -1891,6 +2175,16 @@ func (s *InboundService) ResetAllTraffics() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *InboundService) ResetInboundTraffic(id int) error {
|
||||||
|
db := database.GetDB()
|
||||||
|
|
||||||
|
result := db.Model(model.Inbound{}).
|
||||||
|
Where("id = ?", id).
|
||||||
|
Updates(map[string]any{"up": 0, "down": 0})
|
||||||
|
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
|
||||||
func (s *InboundService) DelDepletedClients(id int) (err error) {
|
func (s *InboundService) DelDepletedClients(id int) (err error) {
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
tx := db.Begin()
|
tx := db.Begin()
|
||||||
@@ -2032,7 +2326,6 @@ func (s *InboundService) GetClientTrafficByEmail(email string) (traffic *xray.Cl
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if t != nil && client != nil {
|
if t != nil && client != nil {
|
||||||
t.Enable = client.Enable
|
|
||||||
t.UUID = client.ID
|
t.UUID = client.ID
|
||||||
t.SubId = client.SubID
|
t.SubId = client.SubID
|
||||||
return t, nil
|
return t, nil
|
||||||
@@ -2141,6 +2434,43 @@ func (s *InboundService) GetInboundClientIps(clientEmail string) (string, error)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if InboundClientIps.Ips == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse as new format (with timestamps)
|
||||||
|
type IPWithTimestamp struct {
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var ipsWithTime []IPWithTimestamp
|
||||||
|
err = json.Unmarshal([]byte(InboundClientIps.Ips), &ipsWithTime)
|
||||||
|
|
||||||
|
// If successfully parsed as new format, return with timestamps
|
||||||
|
if err == nil && len(ipsWithTime) > 0 {
|
||||||
|
return InboundClientIps.Ips, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, assume it's old format (simple string array)
|
||||||
|
// Try to parse as simple array and convert to new format
|
||||||
|
var oldIps []string
|
||||||
|
err = json.Unmarshal([]byte(InboundClientIps.Ips), &oldIps)
|
||||||
|
if err == nil && len(oldIps) > 0 {
|
||||||
|
// Convert old format to new format with current timestamp
|
||||||
|
newIpsWithTime := make([]IPWithTimestamp, len(oldIps))
|
||||||
|
for i, ip := range oldIps {
|
||||||
|
newIpsWithTime[i] = IPWithTimestamp{
|
||||||
|
IP: ip,
|
||||||
|
Timestamp: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result, _ := json.Marshal(newIpsWithTime)
|
||||||
|
return string(result), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return as-is if parsing fails
|
||||||
return InboundClientIps.Ips, nil
|
return InboundClientIps.Ips, 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("")
|
||||||
|
}
|
||||||
@@ -1,9 +1,22 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/config"
|
||||||
"github.com/mhsanaei/3x-ui/v2/database"
|
"github.com/mhsanaei/3x-ui/v2/database"
|
||||||
"github.com/mhsanaei/3x-ui/v2/database/model"
|
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v2/logger"
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/util/common"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/util/json_util"
|
||||||
"github.com/mhsanaei/3x-ui/v2/xray"
|
"github.com/mhsanaei/3x-ui/v2/xray"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -13,6 +26,9 @@ import (
|
|||||||
// It handles outbound traffic monitoring and statistics.
|
// It handles outbound traffic monitoring and statistics.
|
||||||
type OutboundService struct{}
|
type OutboundService struct{}
|
||||||
|
|
||||||
|
// testSemaphore limits concurrent outbound tests to prevent resource exhaustion.
|
||||||
|
var testSemaphore sync.Mutex
|
||||||
|
|
||||||
func (s *OutboundService) AddTraffic(traffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (error, bool) {
|
func (s *OutboundService) AddTraffic(traffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (error, bool) {
|
||||||
var err error
|
var err error
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
@@ -100,3 +116,307 @@ func (s *OutboundService) ResetOutboundTraffic(tag string) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestOutboundResult represents the result of testing an outbound
|
||||||
|
type TestOutboundResult struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Delay int64 `json:"delay"` // Delay in milliseconds
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
StatusCode int `json:"statusCode,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOutbound tests an outbound by creating a temporary xray instance and measuring response time.
|
||||||
|
// allOutboundsJSON must be a JSON array of all outbounds; they are copied into the test config unchanged.
|
||||||
|
// Only the test inbound and a route rule (to the tested outbound tag) are added.
|
||||||
|
func (s *OutboundService) TestOutbound(outboundJSON string, testURL string, allOutboundsJSON string) (*TestOutboundResult, error) {
|
||||||
|
if testURL == "" {
|
||||||
|
testURL = "https://www.google.com/generate_204"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit to one concurrent test at a time
|
||||||
|
if !testSemaphore.TryLock() {
|
||||||
|
return &TestOutboundResult{
|
||||||
|
Success: false,
|
||||||
|
Error: "Another outbound test is already running, please wait",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
defer testSemaphore.Unlock()
|
||||||
|
|
||||||
|
// Parse the outbound being tested to get its tag
|
||||||
|
var testOutbound map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(outboundJSON), &testOutbound); err != nil {
|
||||||
|
return &TestOutboundResult{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("Invalid outbound JSON: %v", err),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
outboundTag, _ := testOutbound["tag"].(string)
|
||||||
|
if outboundTag == "" {
|
||||||
|
return &TestOutboundResult{
|
||||||
|
Success: false,
|
||||||
|
Error: "Outbound has no tag",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
if protocol, _ := testOutbound["protocol"].(string); protocol == "blackhole" || outboundTag == "blocked" {
|
||||||
|
return &TestOutboundResult{
|
||||||
|
Success: false,
|
||||||
|
Error: "Blocked/blackhole outbound cannot be tested",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use all outbounds when provided; otherwise fall back to single outbound
|
||||||
|
var allOutbounds []any
|
||||||
|
if allOutboundsJSON != "" {
|
||||||
|
if err := json.Unmarshal([]byte(allOutboundsJSON), &allOutbounds); err != nil {
|
||||||
|
return &TestOutboundResult{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("Invalid allOutbounds JSON: %v", err),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(allOutbounds) == 0 {
|
||||||
|
allOutbounds = []any{testOutbound}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find an available port for test inbound
|
||||||
|
testPort, err := findAvailablePort()
|
||||||
|
if err != nil {
|
||||||
|
return &TestOutboundResult{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("Failed to find available port: %v", err),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy all outbounds as-is, add only test inbound and route rule
|
||||||
|
testConfig := s.createTestConfig(outboundTag, allOutbounds, testPort)
|
||||||
|
|
||||||
|
// Use a temporary config file so the main config.json is never overwritten
|
||||||
|
testConfigPath, err := createTestConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
return &TestOutboundResult{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("Failed to create test config path: %v", err),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
defer os.Remove(testConfigPath) // ensure temp file is removed even if process is not stopped
|
||||||
|
|
||||||
|
// Create temporary xray process with its own config file
|
||||||
|
testProcess := xray.NewTestProcess(testConfig, testConfigPath)
|
||||||
|
defer func() {
|
||||||
|
if testProcess.IsRunning() {
|
||||||
|
testProcess.Stop()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Start the test process
|
||||||
|
if err := testProcess.Start(); err != nil {
|
||||||
|
return &TestOutboundResult{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("Failed to start test xray instance: %v", err),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for xray to start listening on the test port
|
||||||
|
if err := waitForPort(testPort, 3*time.Second); err != nil {
|
||||||
|
if !testProcess.IsRunning() {
|
||||||
|
result := testProcess.GetResult()
|
||||||
|
return &TestOutboundResult{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("Xray process exited: %s", result),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return &TestOutboundResult{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("Xray failed to start listening: %v", err),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if process is still running
|
||||||
|
if !testProcess.IsRunning() {
|
||||||
|
result := testProcess.GetResult()
|
||||||
|
return &TestOutboundResult{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("Xray process exited: %s", result),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test the connection through proxy
|
||||||
|
delay, statusCode, err := s.testConnection(testPort, testURL)
|
||||||
|
if err != nil {
|
||||||
|
return &TestOutboundResult{
|
||||||
|
Success: false,
|
||||||
|
Error: err.Error(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &TestOutboundResult{
|
||||||
|
Success: true,
|
||||||
|
Delay: delay,
|
||||||
|
StatusCode: statusCode,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTestConfig creates a test config by copying all outbounds unchanged and adding
|
||||||
|
// only the test inbound (SOCKS) and a route rule that sends traffic to the given outbound tag.
|
||||||
|
func (s *OutboundService) createTestConfig(outboundTag string, allOutbounds []any, testPort int) *xray.Config {
|
||||||
|
// Test inbound (SOCKS proxy) - only addition to inbounds
|
||||||
|
testInbound := xray.InboundConfig{
|
||||||
|
Tag: "test-inbound",
|
||||||
|
Listen: json_util.RawMessage(`"127.0.0.1"`),
|
||||||
|
Port: testPort,
|
||||||
|
Protocol: "socks",
|
||||||
|
Settings: json_util.RawMessage(`{"auth":"noauth","udp":true}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outbounds: copy all, but set noKernelTun=true for WireGuard outbounds
|
||||||
|
processedOutbounds := make([]any, len(allOutbounds))
|
||||||
|
for i, ob := range allOutbounds {
|
||||||
|
outbound, ok := ob.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
processedOutbounds[i] = ob
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if protocol, ok := outbound["protocol"].(string); ok && protocol == "wireguard" {
|
||||||
|
// Set noKernelTun to true for WireGuard outbounds
|
||||||
|
if settings, ok := outbound["settings"].(map[string]any); ok {
|
||||||
|
settings["noKernelTun"] = true
|
||||||
|
} else {
|
||||||
|
// Create settings if it doesn't exist
|
||||||
|
outbound["settings"] = map[string]any{
|
||||||
|
"noKernelTun": true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
processedOutbounds[i] = outbound
|
||||||
|
}
|
||||||
|
outboundsJSON, _ := json.Marshal(processedOutbounds)
|
||||||
|
|
||||||
|
// Create routing rule to route all traffic through test outbound
|
||||||
|
routingRules := []map[string]any{
|
||||||
|
{
|
||||||
|
"type": "field",
|
||||||
|
"outboundTag": outboundTag,
|
||||||
|
"network": "tcp,udp",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
routingJSON, _ := json.Marshal(map[string]any{
|
||||||
|
"domainStrategy": "AsIs",
|
||||||
|
"rules": routingRules,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Disable logging for test process to avoid creating orphaned log files
|
||||||
|
logConfig := map[string]any{
|
||||||
|
"loglevel": "warning",
|
||||||
|
"access": "none",
|
||||||
|
"error": "none",
|
||||||
|
"dnsLog": false,
|
||||||
|
}
|
||||||
|
logJSON, _ := json.Marshal(logConfig)
|
||||||
|
|
||||||
|
// Create minimal config
|
||||||
|
cfg := &xray.Config{
|
||||||
|
LogConfig: json_util.RawMessage(logJSON),
|
||||||
|
InboundConfigs: []xray.InboundConfig{
|
||||||
|
testInbound,
|
||||||
|
},
|
||||||
|
OutboundConfigs: json_util.RawMessage(string(outboundsJSON)),
|
||||||
|
RouterConfig: json_util.RawMessage(string(routingJSON)),
|
||||||
|
Policy: json_util.RawMessage(`{}`),
|
||||||
|
Stats: json_util.RawMessage(`{}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// testConnection tests the connection through the proxy and measures delay.
|
||||||
|
// It performs a warmup request first to establish the SOCKS connection and populate DNS caches,
|
||||||
|
// then measures the second request for a more accurate latency reading.
|
||||||
|
func (s *OutboundService) testConnection(proxyPort int, testURL string) (int64, int, error) {
|
||||||
|
// Create SOCKS5 proxy URL
|
||||||
|
proxyURL := fmt.Sprintf("socks5://127.0.0.1:%d", proxyPort)
|
||||||
|
|
||||||
|
// Parse proxy URL
|
||||||
|
proxyURLParsed, err := url.Parse(proxyURL)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, common.NewErrorf("Invalid proxy URL: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create HTTP client with proxy and keep-alive for connection reuse
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
Proxy: http.ProxyURL(proxyURLParsed),
|
||||||
|
DialContext: (&net.Dialer{
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
KeepAlive: 30 * time.Second,
|
||||||
|
}).DialContext,
|
||||||
|
MaxIdleConns: 1,
|
||||||
|
IdleConnTimeout: 10 * time.Second,
|
||||||
|
DisableCompression: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warmup request: establishes SOCKS/TLS connection, DNS, and TCP to the target.
|
||||||
|
// This mirrors real-world usage where connections are reused.
|
||||||
|
warmupResp, err := client.Get(testURL)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, common.NewErrorf("Request failed: %v", err)
|
||||||
|
}
|
||||||
|
io.Copy(io.Discard, warmupResp.Body)
|
||||||
|
warmupResp.Body.Close()
|
||||||
|
|
||||||
|
// Measure the actual request on the warm connection
|
||||||
|
startTime := time.Now()
|
||||||
|
resp, err := client.Get(testURL)
|
||||||
|
delay := time.Since(startTime).Milliseconds()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, common.NewErrorf("Request failed: %v", err)
|
||||||
|
}
|
||||||
|
io.Copy(io.Discard, resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
return delay, resp.StatusCode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitForPort polls until the given TCP port is accepting connections or the timeout expires.
|
||||||
|
func waitForPort(port int, timeout time.Duration) error {
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 100*time.Millisecond)
|
||||||
|
if err == nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("port %d not ready after %v", port, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// findAvailablePort finds an available port for testing
|
||||||
|
func findAvailablePort() (int, error) {
|
||||||
|
listener, err := net.Listen("tcp", ":0")
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
|
||||||
|
addr := listener.Addr().(*net.TCPAddr)
|
||||||
|
return addr.Port, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTestConfigPath returns a unique path for a temporary xray config file in the bin folder.
|
||||||
|
// The temp file is created and closed so the path is reserved; Start() will overwrite it.
|
||||||
|
func createTestConfigPath() (string, error) {
|
||||||
|
tmpFile, err := os.CreateTemp(config.GetBinFolderPath(), "xray_test_*.json")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
path := tmpFile.Name()
|
||||||
|
if err := tmpFile.Close(); err != nil {
|
||||||
|
os.Remove(path)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|||||||
+70
-49
@@ -567,7 +567,7 @@ func (s *ServerService) GetXrayVersions() ([]string, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if major > 26 || (major == 26 && minor > 1) || (major == 26 && minor == 1 && patch >= 31) {
|
if major > 26 || (major == 26 && minor > 3) || (major == 26 && minor == 3 && patch >= 10) {
|
||||||
versions = append(versions, release.TagName)
|
versions = append(versions, release.TagName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1056,44 +1056,79 @@ func (s *ServerService) IsValidGeofileName(filename string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *ServerService) UpdateGeofile(fileName string) error {
|
func (s *ServerService) UpdateGeofile(fileName string) error {
|
||||||
files := []struct {
|
type geofileEntry struct {
|
||||||
URL string
|
URL string
|
||||||
FileName string
|
FileName string
|
||||||
}{
|
}
|
||||||
{"https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat", "geoip.dat"},
|
geofileAllowlist := map[string]geofileEntry{
|
||||||
{"https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat", "geosite.dat"},
|
"geoip.dat": {"https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat", "geoip.dat"},
|
||||||
{"https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat", "geoip_IR.dat"},
|
"geosite.dat": {"https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat", "geosite.dat"},
|
||||||
{"https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat", "geosite_IR.dat"},
|
"geoip_IR.dat": {"https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat", "geoip_IR.dat"},
|
||||||
{"https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat", "geoip_RU.dat"},
|
"geosite_IR.dat": {"https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat", "geosite_IR.dat"},
|
||||||
{"https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat", "geosite_RU.dat"},
|
"geoip_RU.dat": {"https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat", "geoip_RU.dat"},
|
||||||
|
"geosite_RU.dat": {"https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat", "geosite_RU.dat"},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strict allowlist check to avoid writing uncontrolled files
|
// Strict allowlist check to avoid writing uncontrolled files
|
||||||
if fileName != "" {
|
if fileName != "" {
|
||||||
// Use the centralized validation function
|
if _, ok := geofileAllowlist[fileName]; !ok {
|
||||||
if !s.IsValidGeofileName(fileName) {
|
return common.NewErrorf("Invalid geofile name: %q not in allowlist", fileName)
|
||||||
return common.NewErrorf("Invalid geofile name: contains unsafe path characters: %s", fileName)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure the filename matches exactly one from our allowlist
|
|
||||||
isAllowed := false
|
|
||||||
for _, file := range files {
|
|
||||||
if fileName == file.FileName {
|
|
||||||
isAllowed = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !isAllowed {
|
|
||||||
return common.NewErrorf("Invalid geofile name: %s not in allowlist", fileName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
downloadFile := func(url, destPath string) error {
|
downloadFile := func(url, destPath string) error {
|
||||||
resp, err := http.Get(url)
|
var req *http.Request
|
||||||
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return common.NewErrorf("Failed to create HTTP request for %s: %v", url, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var localFileModTime time.Time
|
||||||
|
if fileInfo, err := os.Stat(destPath); err == nil {
|
||||||
|
localFileModTime = fileInfo.ModTime()
|
||||||
|
if !localFileModTime.IsZero() {
|
||||||
|
req.Header.Set("If-Modified-Since", localFileModTime.UTC().Format(http.TimeFormat))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return common.NewErrorf("Failed to download Geofile from %s: %v", url, err)
|
return common.NewErrorf("Failed to download Geofile from %s: %v", url, err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Parse Last-Modified header from server
|
||||||
|
var serverModTime time.Time
|
||||||
|
serverModTimeStr := resp.Header.Get("Last-Modified")
|
||||||
|
if serverModTimeStr != "" {
|
||||||
|
parsedTime, err := time.Parse(http.TimeFormat, serverModTimeStr)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warningf("Failed to parse Last-Modified header for %s: %v", url, err)
|
||||||
|
} else {
|
||||||
|
serverModTime = parsedTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to update local file's modification time
|
||||||
|
updateFileModTime := func() {
|
||||||
|
if !serverModTime.IsZero() {
|
||||||
|
if err := os.Chtimes(destPath, serverModTime, serverModTime); err != nil {
|
||||||
|
logger.Warningf("Failed to update modification time for %s: %v", destPath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle 304 Not Modified
|
||||||
|
if resp.StatusCode == http.StatusNotModified {
|
||||||
|
updateFileModTime()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return common.NewErrorf("Failed to download Geofile from %s: received status code %d", url, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
file, err := os.Create(destPath)
|
file, err := os.Create(destPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return common.NewErrorf("Failed to create Geofile %s: %v", destPath, err)
|
return common.NewErrorf("Failed to create Geofile %s: %v", destPath, err)
|
||||||
@@ -1105,39 +1140,25 @@ func (s *ServerService) UpdateGeofile(fileName string) error {
|
|||||||
return common.NewErrorf("Failed to save Geofile %s: %v", destPath, err)
|
return common.NewErrorf("Failed to save Geofile %s: %v", destPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateFileModTime()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var errorMessages []string
|
var errorMessages []string
|
||||||
|
|
||||||
if fileName == "" {
|
if fileName == "" {
|
||||||
for _, file := range files {
|
// Download all geofiles
|
||||||
// Sanitize the filename from our allowlist as an extra precaution
|
for _, entry := range geofileAllowlist {
|
||||||
destPath := filepath.Join(config.GetBinFolderPath(), filepath.Base(file.FileName))
|
destPath := filepath.Join(config.GetBinFolderPath(), entry.FileName)
|
||||||
|
if err := downloadFile(entry.URL, destPath); err != nil {
|
||||||
if err := downloadFile(file.URL, destPath); err != nil {
|
errorMessages = append(errorMessages, fmt.Sprintf("Error downloading Geofile '%s': %v", entry.FileName, err))
|
||||||
errorMessages = append(errorMessages, fmt.Sprintf("Error downloading Geofile '%s': %v", file.FileName, err))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Use filepath.Base to ensure we only get the filename component, no path traversal
|
entry := geofileAllowlist[fileName]
|
||||||
safeName := filepath.Base(fileName)
|
destPath := filepath.Join(config.GetBinFolderPath(), entry.FileName)
|
||||||
destPath := filepath.Join(config.GetBinFolderPath(), safeName)
|
if err := downloadFile(entry.URL, destPath); err != nil {
|
||||||
|
errorMessages = append(errorMessages, fmt.Sprintf("Error downloading Geofile '%s': %v", entry.FileName, err))
|
||||||
var fileURL string
|
|
||||||
for _, file := range files {
|
|
||||||
if file.FileName == fileName {
|
|
||||||
fileURL = file.URL
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if fileURL == "" {
|
|
||||||
errorMessages = append(errorMessages, fmt.Sprintf("File '%s' not found in the list of Geofiles", fileName))
|
|
||||||
} else {
|
|
||||||
if err := downloadFile(fileURL, destPath); err != nil {
|
|
||||||
errorMessages = append(errorMessages, fmt.Sprintf("Error downloading Geofile '%s': %v", fileName, err))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+81
-7
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -70,14 +71,20 @@ var defaultValueMap = map[string]string{
|
|||||||
"subURI": "",
|
"subURI": "",
|
||||||
"subJsonPath": "/json/",
|
"subJsonPath": "/json/",
|
||||||
"subJsonURI": "",
|
"subJsonURI": "",
|
||||||
|
"subClashEnable": "true",
|
||||||
|
"subClashPath": "/clash/",
|
||||||
|
"subClashURI": "",
|
||||||
"subJsonFragment": "",
|
"subJsonFragment": "",
|
||||||
"subJsonNoises": "",
|
"subJsonNoises": "",
|
||||||
"subJsonMux": "",
|
"subJsonMux": "",
|
||||||
"subJsonRules": "",
|
"subJsonRules": "",
|
||||||
"datepicker": "gregorian",
|
"datepicker": "gregorian",
|
||||||
"warp": "",
|
"warp": "",
|
||||||
|
"nord": "",
|
||||||
"externalTrafficInformEnable": "false",
|
"externalTrafficInformEnable": "false",
|
||||||
"externalTrafficInformURI": "",
|
"externalTrafficInformURI": "",
|
||||||
|
"xrayOutboundTestUrl": "https://www.google.com/generate_204",
|
||||||
|
|
||||||
// LDAP defaults
|
// LDAP defaults
|
||||||
"ldapEnable": "false",
|
"ldapEnable": "false",
|
||||||
"ldapHost": "",
|
"ldapHost": "",
|
||||||
@@ -105,7 +112,7 @@ var defaultValueMap = map[string]string{
|
|||||||
// It handles configuration storage, retrieval, and validation for all system settings.
|
// It handles configuration storage, retrieval, and validation for all system settings.
|
||||||
type SettingService struct{}
|
type SettingService struct{}
|
||||||
|
|
||||||
func (s *SettingService) GetDefaultJsonConfig() (any, error) {
|
func (s *SettingService) GetDefaultJSONConfig() (any, error) {
|
||||||
var jsonData any
|
var jsonData any
|
||||||
err := json.Unmarshal([]byte(xrayTemplateConfig), &jsonData)
|
err := json.Unmarshal([]byte(xrayTemplateConfig), &jsonData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -122,7 +129,7 @@ func (s *SettingService) GetAllSetting() (*entity.AllSetting, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
allSetting := &entity.AllSetting{}
|
allSetting := &entity.AllSetting{}
|
||||||
t := reflect.TypeOf(allSetting).Elem()
|
t := reflect.TypeFor[entity.AllSetting]()
|
||||||
v := reflect.ValueOf(allSetting).Elem()
|
v := reflect.ValueOf(allSetting).Elem()
|
||||||
fields := reflect_util.GetFields(t)
|
fields := reflect_util.GetFields(t)
|
||||||
|
|
||||||
@@ -271,6 +278,14 @@ func (s *SettingService) GetXrayConfigTemplate() (string, error) {
|
|||||||
return s.getString("xrayTemplateConfig")
|
return s.getString("xrayTemplateConfig")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SettingService) GetXrayOutboundTestUrl() (string, error) {
|
||||||
|
return s.getString("xrayOutboundTestUrl")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingService) SetXrayOutboundTestUrl(url string) error {
|
||||||
|
return s.setString("xrayOutboundTestUrl", url)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SettingService) GetListen() (string, error) {
|
func (s *SettingService) GetListen() (string, error) {
|
||||||
return s.getString("webListen")
|
return s.getString("webListen")
|
||||||
}
|
}
|
||||||
@@ -447,7 +462,12 @@ func (s *SettingService) GetTimeLocation() (*time.Location, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
defaultLocation := defaultValueMap["timeLocation"]
|
defaultLocation := defaultValueMap["timeLocation"]
|
||||||
logger.Errorf("location <%v> not exist, using default location: %v", l, defaultLocation)
|
logger.Errorf("location <%v> not exist, using default location: %v", l, defaultLocation)
|
||||||
return time.LoadLocation(defaultLocation)
|
location, err = time.LoadLocation(defaultLocation)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("failed to load default location, using UTC: %v", err)
|
||||||
|
return time.UTC, nil
|
||||||
|
}
|
||||||
|
return location, nil
|
||||||
}
|
}
|
||||||
return location, nil
|
return location, nil
|
||||||
}
|
}
|
||||||
@@ -544,6 +564,18 @@ func (s *SettingService) GetSubJsonURI() (string, error) {
|
|||||||
return s.getString("subJsonURI")
|
return s.getString("subJsonURI")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SettingService) GetSubClashEnable() (bool, error) {
|
||||||
|
return s.getBool("subClashEnable")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingService) GetSubClashPath() (string, error) {
|
||||||
|
return s.getString("subClashPath")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingService) GetSubClashURI() (string, error) {
|
||||||
|
return s.getString("subClashURI")
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SettingService) GetSubJsonFragment() (string, error) {
|
func (s *SettingService) GetSubJsonFragment() (string, error) {
|
||||||
return s.getString("subJsonFragment")
|
return s.getString("subJsonFragment")
|
||||||
}
|
}
|
||||||
@@ -572,6 +604,14 @@ func (s *SettingService) SetWarp(data string) error {
|
|||||||
return s.setString("warp", data)
|
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) {
|
func (s *SettingService) GetExternalTrafficInformEnable() (bool, error) {
|
||||||
return s.getBool("externalTrafficInformEnable")
|
return s.getBool("externalTrafficInformEnable")
|
||||||
}
|
}
|
||||||
@@ -596,7 +636,7 @@ func (s *SettingService) GetIpLimitEnable() (bool, error) {
|
|||||||
return (accessLogPath != "none" && accessLogPath != ""), nil
|
return (accessLogPath != "none" && accessLogPath != ""), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LDAP exported getters
|
// GetLdapEnable returns whether LDAP is enabled.
|
||||||
func (s *SettingService) GetLdapEnable() (bool, error) {
|
func (s *SettingService) GetLdapEnable() (bool, error) {
|
||||||
return s.getBool("ldapEnable")
|
return s.getBool("ldapEnable")
|
||||||
}
|
}
|
||||||
@@ -683,7 +723,7 @@ func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
v := reflect.ValueOf(allSetting).Elem()
|
v := reflect.ValueOf(allSetting).Elem()
|
||||||
t := reflect.TypeOf(allSetting).Elem()
|
t := reflect.TypeFor[entity.AllSetting]()
|
||||||
fields := reflect_util.GetFields(t)
|
fields := reflect_util.GetFields(t)
|
||||||
errs := make([]error, 0)
|
errs := make([]error, 0)
|
||||||
for _, field := range fields {
|
for _, field := range fields {
|
||||||
@@ -707,6 +747,28 @@ func (s *SettingService) GetDefaultXrayConfig() (any, error) {
|
|||||||
return jsonData, nil
|
return jsonData, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func extractHostname(host string) string {
|
||||||
|
h, _, err := net.SplitHostPort(host)
|
||||||
|
// Err is not nil means host does not contain port
|
||||||
|
if err != nil {
|
||||||
|
h = host
|
||||||
|
}
|
||||||
|
|
||||||
|
ip := net.ParseIP(h)
|
||||||
|
// If it's not an IP, return as is
|
||||||
|
if ip == nil {
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's an IPv4, return as is
|
||||||
|
if ip.To4() != nil {
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPv6 needs bracketing
|
||||||
|
return "[" + h + "]"
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SettingService) GetDefaultSettings(host string) (any, error) {
|
func (s *SettingService) GetDefaultSettings(host string) (any, error) {
|
||||||
type settingFunc func() (any, error)
|
type settingFunc func() (any, error)
|
||||||
settings := map[string]settingFunc{
|
settings := map[string]settingFunc{
|
||||||
@@ -718,9 +780,11 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
|
|||||||
"tgBotEnable": func() (any, error) { return s.GetTgbotEnabled() },
|
"tgBotEnable": func() (any, error) { return s.GetTgbotEnabled() },
|
||||||
"subEnable": func() (any, error) { return s.GetSubEnable() },
|
"subEnable": func() (any, error) { return s.GetSubEnable() },
|
||||||
"subJsonEnable": func() (any, error) { return s.GetSubJsonEnable() },
|
"subJsonEnable": func() (any, error) { return s.GetSubJsonEnable() },
|
||||||
|
"subClashEnable": func() (any, error) { return s.GetSubClashEnable() },
|
||||||
"subTitle": func() (any, error) { return s.GetSubTitle() },
|
"subTitle": func() (any, error) { return s.GetSubTitle() },
|
||||||
"subURI": func() (any, error) { return s.GetSubURI() },
|
"subURI": func() (any, error) { return s.GetSubURI() },
|
||||||
"subJsonURI": func() (any, error) { return s.GetSubJsonURI() },
|
"subJsonURI": func() (any, error) { return s.GetSubJsonURI() },
|
||||||
|
"subClashURI": func() (any, error) { return s.GetSubClashURI() },
|
||||||
"remarkModel": func() (any, error) { return s.GetRemarkModel() },
|
"remarkModel": func() (any, error) { return s.GetRemarkModel() },
|
||||||
"datepicker": func() (any, error) { return s.GetDatepicker() },
|
"datepicker": func() (any, error) { return s.GetDatepicker() },
|
||||||
"ipLimitEnable": func() (any, error) { return s.GetIpLimitEnable() },
|
"ipLimitEnable": func() (any, error) { return s.GetIpLimitEnable() },
|
||||||
@@ -743,12 +807,19 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
|
|||||||
subJsonEnable = b
|
subJsonEnable = b
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (subEnable && result["subURI"].(string) == "") || (subJsonEnable && result["subJsonURI"].(string) == "") {
|
subClashEnable := false
|
||||||
|
if v, ok := result["subClashEnable"]; ok {
|
||||||
|
if b, ok2 := v.(bool); ok2 {
|
||||||
|
subClashEnable = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (subEnable && result["subURI"].(string) == "") || (subJsonEnable && result["subJsonURI"].(string) == "") || (subClashEnable && result["subClashURI"].(string) == "") {
|
||||||
subURI := ""
|
subURI := ""
|
||||||
subTitle, _ := s.GetSubTitle()
|
subTitle, _ := s.GetSubTitle()
|
||||||
subPort, _ := s.GetSubPort()
|
subPort, _ := s.GetSubPort()
|
||||||
subPath, _ := s.GetSubPath()
|
subPath, _ := s.GetSubPath()
|
||||||
subJsonPath, _ := s.GetSubJsonPath()
|
subJsonPath, _ := s.GetSubJsonPath()
|
||||||
|
subClashPath, _ := s.GetSubClashPath()
|
||||||
subDomain, _ := s.GetSubDomain()
|
subDomain, _ := s.GetSubDomain()
|
||||||
subKeyFile, _ := s.GetSubKeyFile()
|
subKeyFile, _ := s.GetSubKeyFile()
|
||||||
subCertFile, _ := s.GetSubCertFile()
|
subCertFile, _ := s.GetSubCertFile()
|
||||||
@@ -757,7 +828,7 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
|
|||||||
subTLS = true
|
subTLS = true
|
||||||
}
|
}
|
||||||
if subDomain == "" {
|
if subDomain == "" {
|
||||||
subDomain = strings.Split(host, ":")[0]
|
subDomain = extractHostname(host)
|
||||||
}
|
}
|
||||||
if subTLS {
|
if subTLS {
|
||||||
subURI = "https://"
|
subURI = "https://"
|
||||||
@@ -778,6 +849,9 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
|
|||||||
if subJsonEnable && result["subJsonURI"].(string) == "" {
|
if subJsonEnable && result["subJsonURI"].(string) == "" {
|
||||||
result["subJsonURI"] = subURI + subJsonPath
|
result["subJsonURI"] = subURI + subJsonPath
|
||||||
}
|
}
|
||||||
|
if subClashEnable && result["subClashURI"].(string) == "" {
|
||||||
|
result["subClashURI"] = subURI + subClashPath
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
|
|||||||
+214
-121
@@ -5,8 +5,10 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"embed"
|
"embed"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html"
|
||||||
"io"
|
"io"
|
||||||
"math/big"
|
"math/big"
|
||||||
"net"
|
"net"
|
||||||
@@ -14,6 +16,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -246,18 +249,7 @@ func (t *Tgbot) Start(i18nFS embed.FS) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// After bot initialization, set up bot commands with localized descriptions
|
t.trySetBotCommands(bot)
|
||||||
err = bot.SetMyCommands(context.Background(), &telego.SetMyCommandsParams{
|
|
||||||
Commands: []telego.BotCommand{
|
|
||||||
{Command: "start", Description: t.I18nBot("tgbot.commands.startDesc")},
|
|
||||||
{Command: "help", Description: t.I18nBot("tgbot.commands.helpDesc")},
|
|
||||||
{Command: "status", Description: t.I18nBot("tgbot.commands.statusDesc")},
|
|
||||||
{Command: "id", Description: t.I18nBot("tgbot.commands.idDesc")},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
logger.Warning("Failed to set bot commands:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start receiving Telegram bot messages
|
// Start receiving Telegram bot messages
|
||||||
tgBotMutex.Lock()
|
tgBotMutex.Lock()
|
||||||
@@ -271,41 +263,98 @@ func (t *Tgbot) Start(i18nFS embed.FS) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *Tgbot) trySetBotCommands(bot *telego.Bot) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
logger.Warning("Failed to register bot commands (Telegram may be rate-limiting); bot will continue without them:", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
err := bot.SetMyCommands(context.Background(), &telego.SetMyCommandsParams{
|
||||||
|
Commands: []telego.BotCommand{
|
||||||
|
{Command: "start", Description: t.I18nBot("tgbot.commands.startDesc")},
|
||||||
|
{Command: "help", Description: t.I18nBot("tgbot.commands.helpDesc")},
|
||||||
|
{Command: "status", Description: t.I18nBot("tgbot.commands.statusDesc")},
|
||||||
|
{Command: "id", Description: t.I18nBot("tgbot.commands.idDesc")},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
logger.Warning("Failed to set bot commands:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createRobustFastHTTPClient creates a fasthttp.Client with proper connection handling
|
||||||
|
func (t *Tgbot) createRobustFastHTTPClient(proxyUrl string) *fasthttp.Client {
|
||||||
|
client := &fasthttp.Client{
|
||||||
|
// Connection timeouts
|
||||||
|
ReadTimeout: 30 * time.Second,
|
||||||
|
WriteTimeout: 30 * time.Second,
|
||||||
|
MaxIdleConnDuration: 60 * time.Second,
|
||||||
|
MaxConnDuration: 0, // unlimited, but controlled by MaxIdleConnDuration
|
||||||
|
MaxIdemponentCallAttempts: 3,
|
||||||
|
ReadBufferSize: 4096,
|
||||||
|
WriteBufferSize: 4096,
|
||||||
|
MaxConnsPerHost: 100,
|
||||||
|
MaxConnWaitTimeout: 10 * time.Second,
|
||||||
|
DisableHeaderNamesNormalizing: false,
|
||||||
|
DisablePathNormalizing: false,
|
||||||
|
// Retry on connection errors
|
||||||
|
RetryIf: func(request *fasthttp.Request) bool {
|
||||||
|
// Retry on connection errors for GET requests
|
||||||
|
return string(request.Header.Method()) == "GET" || string(request.Header.Method()) == "POST"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set proxy if provided
|
||||||
|
if proxyUrl != "" {
|
||||||
|
client.Dial = fasthttpproxy.FasthttpSocksDialer(proxyUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
// NewBot creates a new Telegram bot instance with optional proxy and API server settings.
|
// NewBot creates a new Telegram bot instance with optional proxy and API server settings.
|
||||||
func (t *Tgbot) NewBot(token string, proxyUrl string, apiServerUrl string) (*telego.Bot, error) {
|
func (t *Tgbot) NewBot(token string, proxyUrl string, apiServerUrl string) (*telego.Bot, error) {
|
||||||
if proxyUrl == "" && apiServerUrl == "" {
|
// Validate proxy URL if provided
|
||||||
return telego.NewBot(token)
|
|
||||||
}
|
|
||||||
|
|
||||||
if proxyUrl != "" {
|
if proxyUrl != "" {
|
||||||
if !strings.HasPrefix(proxyUrl, "socks5://") {
|
if !strings.HasPrefix(proxyUrl, "socks5://") {
|
||||||
logger.Warning("Invalid socks5 URL, using default")
|
logger.Warning("Invalid socks5 URL, ignoring proxy")
|
||||||
return telego.NewBot(token)
|
proxyUrl = "" // Clear invalid proxy
|
||||||
}
|
} else {
|
||||||
|
|
||||||
_, err := url.Parse(proxyUrl)
|
_, err := url.Parse(proxyUrl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warningf("Can't parse proxy URL, using default instance for tgbot: %v", err)
|
logger.Warningf("Can't parse proxy URL, ignoring proxy: %v", err)
|
||||||
return telego.NewBot(token)
|
proxyUrl = ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return telego.NewBot(token, telego.WithFastHTTPClient(&fasthttp.Client{
|
|
||||||
Dial: fasthttpproxy.FasthttpSocksDialer(proxyUrl),
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate API server URL if provided
|
||||||
|
if apiServerUrl != "" {
|
||||||
if !strings.HasPrefix(apiServerUrl, "http") {
|
if !strings.HasPrefix(apiServerUrl, "http") {
|
||||||
logger.Warning("Invalid http(s) URL, using default")
|
logger.Warning("Invalid http(s) URL for API server, using default")
|
||||||
return telego.NewBot(token)
|
apiServerUrl = ""
|
||||||
}
|
} else {
|
||||||
|
|
||||||
_, err := url.Parse(apiServerUrl)
|
_, err := url.Parse(apiServerUrl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warningf("Can't parse API server URL, using default instance for tgbot: %v", err)
|
logger.Warningf("Can't parse API server URL, using default: %v", err)
|
||||||
return telego.NewBot(token)
|
apiServerUrl = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return telego.NewBot(token, telego.WithAPIServer(apiServerUrl))
|
// Create robust fasthttp client
|
||||||
|
client := t.createRobustFastHTTPClient(proxyUrl)
|
||||||
|
|
||||||
|
// Build bot options
|
||||||
|
var options []telego.BotOption
|
||||||
|
options = append(options, telego.WithFastHTTPClient(client))
|
||||||
|
|
||||||
|
if apiServerUrl != "" {
|
||||||
|
options = append(options, telego.WithAPIServer(apiServerUrl))
|
||||||
|
}
|
||||||
|
|
||||||
|
return telego.NewBot(token, options...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsRunning checks if the Telegram bot is currently running.
|
// IsRunning checks if the Telegram bot is currently running.
|
||||||
@@ -389,7 +438,7 @@ func (t *Tgbot) decodeQuery(query string) (string, error) {
|
|||||||
// OnReceive starts the message receiving loop for the Telegram bot.
|
// OnReceive starts the message receiving loop for the Telegram bot.
|
||||||
func (t *Tgbot) OnReceive() {
|
func (t *Tgbot) OnReceive() {
|
||||||
params := telego.GetUpdatesParams{
|
params := telego.GetUpdatesParams{
|
||||||
Timeout: 30, // Increased timeout to reduce API calls
|
Timeout: 20, // Reduced timeout to detect connection issues faster
|
||||||
}
|
}
|
||||||
// Strict singleton: never start a second long-polling loop.
|
// Strict singleton: never start a second long-polling loop.
|
||||||
tgBotMutex.Lock()
|
tgBotMutex.Lock()
|
||||||
@@ -407,7 +456,7 @@ func (t *Tgbot) OnReceive() {
|
|||||||
botWG.Add(1)
|
botWG.Add(1)
|
||||||
tgBotMutex.Unlock()
|
tgBotMutex.Unlock()
|
||||||
|
|
||||||
// Get updates channel using the context.
|
// Get updates channel using the context with shorter timeout for better error recovery
|
||||||
updates, _ := bot.UpdatesViaLongPolling(ctx, ¶ms)
|
updates, _ := bot.UpdatesViaLongPolling(ctx, ¶ms)
|
||||||
go func() {
|
go func() {
|
||||||
defer botWG.Done()
|
defer botWG.Done()
|
||||||
@@ -613,7 +662,7 @@ func (t *Tgbot) answerCommand(message *telego.Message, chatId int64, isAdmin boo
|
|||||||
msg += t.I18nBot("tgbot.commands.help")
|
msg += t.I18nBot("tgbot.commands.help")
|
||||||
msg += t.I18nBot("tgbot.commands.pleaseChoose")
|
msg += t.I18nBot("tgbot.commands.pleaseChoose")
|
||||||
case "start":
|
case "start":
|
||||||
msg += t.I18nBot("tgbot.commands.start", "Firstname=="+message.From.FirstName)
|
msg += t.I18nBot("tgbot.commands.start", "Firstname=="+html.EscapeString(message.From.FirstName))
|
||||||
if isAdmin {
|
if isAdmin {
|
||||||
msg += t.I18nBot("tgbot.commands.welcome", "Hostname=="+hostname)
|
msg += t.I18nBot("tgbot.commands.welcome", "Hostname=="+hostname)
|
||||||
}
|
}
|
||||||
@@ -1886,6 +1935,8 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
|
|||||||
} else {
|
} else {
|
||||||
t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
|
t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
|
||||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.successfulOperation"), tu.ReplyKeyboardRemove())
|
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.successfulOperation"), tu.ReplyKeyboardRemove())
|
||||||
|
t.sendClientIndividualLinks(chatId, client_Email)
|
||||||
|
t.sendClientQRLinks(chatId, client_Email)
|
||||||
}
|
}
|
||||||
case "add_client_submit_enable":
|
case "add_client_submit_enable":
|
||||||
client_Enable = true
|
client_Enable = true
|
||||||
@@ -1896,6 +1947,8 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
|
|||||||
} else {
|
} else {
|
||||||
t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
|
t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
|
||||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.successfulOperation"), tu.ReplyKeyboardRemove())
|
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.successfulOperation"), tu.ReplyKeyboardRemove())
|
||||||
|
t.sendClientIndividualLinks(chatId, client_Email)
|
||||||
|
t.sendClientQRLinks(chatId, client_Email)
|
||||||
}
|
}
|
||||||
case "reset_all_traffics_cancel":
|
case "reset_all_traffics_cancel":
|
||||||
t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
|
t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
|
||||||
@@ -2246,10 +2299,36 @@ func (t *Tgbot) SendMsgToTgbot(chatId int64, msg string, replyMarkup ...telego.R
|
|||||||
if len(replyMarkup) > 0 && n == (len(allMessages)-1) {
|
if len(replyMarkup) > 0 && n == (len(allMessages)-1) {
|
||||||
params.ReplyMarkup = replyMarkup[0]
|
params.ReplyMarkup = replyMarkup[0]
|
||||||
}
|
}
|
||||||
_, err := bot.SendMessage(context.Background(), ¶ms)
|
|
||||||
if err != nil {
|
// Retry logic with exponential backoff for connection errors
|
||||||
logger.Warning("Error sending telegram message :", err)
|
maxRetries := 3
|
||||||
|
for attempt := range maxRetries {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
_, err := bot.SendMessage(ctx, ¶ms)
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
break // Success
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if error is a connection error
|
||||||
|
errStr := err.Error()
|
||||||
|
isConnectionError := strings.Contains(errStr, "connection") ||
|
||||||
|
strings.Contains(errStr, "timeout") ||
|
||||||
|
strings.Contains(errStr, "closed")
|
||||||
|
|
||||||
|
if isConnectionError && attempt < maxRetries-1 {
|
||||||
|
// Exponential backoff: 1s, 2s, 4s
|
||||||
|
backoff := time.Duration(1<<uint(attempt)) * time.Second
|
||||||
|
logger.Warningf("Connection error sending telegram message (attempt %d/%d), retrying in %v: %v",
|
||||||
|
attempt+1, maxRetries, backoff, err)
|
||||||
|
time.Sleep(backoff)
|
||||||
|
} else {
|
||||||
|
logger.Warning("Error sending telegram message:", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Reduced delay to improve performance (only needed for rate limiting)
|
// Reduced delay to improve performance (only needed for rate limiting)
|
||||||
if n < len(allMessages)-1 { // Only delay between messages, not after the last one
|
if n < len(allMessages)-1 { // Only delay between messages, not after the last one
|
||||||
time.Sleep(100 * time.Millisecond)
|
time.Sleep(100 * time.Millisecond)
|
||||||
@@ -2584,8 +2663,12 @@ func (t *Tgbot) SendBackupToAdmins() {
|
|||||||
if !t.IsRunning() {
|
if !t.IsRunning() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, adminId := range adminIds {
|
for i, adminId := range adminIds {
|
||||||
t.sendBackup(int64(adminId))
|
t.sendBackup(int64(adminId))
|
||||||
|
// Add delay between sends to avoid Telegram rate limits
|
||||||
|
if i < len(adminIds)-1 {
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2650,7 +2733,7 @@ func (t *Tgbot) prepareServerUsageInfo() string {
|
|||||||
info += t.I18nBot("tgbot.messages.ip", "IP=="+t.I18nBot("tgbot.unknown"))
|
info += t.I18nBot("tgbot.messages.ip", "IP=="+t.I18nBot("tgbot.unknown"))
|
||||||
info += "\r\n"
|
info += "\r\n"
|
||||||
} else {
|
} else {
|
||||||
for i := 0; i < len(netInterfaces); i++ {
|
for i := range netInterfaces {
|
||||||
if (netInterfaces[i].Flags & net.FlagUp) != 0 {
|
if (netInterfaces[i].Flags & net.FlagUp) != 0 {
|
||||||
addrs, _ := netInterfaces[i].Addrs()
|
addrs, _ := netInterfaces[i].Addrs()
|
||||||
|
|
||||||
@@ -2719,29 +2802,29 @@ func (t *Tgbot) UserLoginNotify(username string, password string, ip string, tim
|
|||||||
|
|
||||||
// getInboundUsages retrieves and formats inbound usage information.
|
// getInboundUsages retrieves and formats inbound usage information.
|
||||||
func (t *Tgbot) getInboundUsages() string {
|
func (t *Tgbot) getInboundUsages() string {
|
||||||
info := ""
|
var info strings.Builder
|
||||||
// get traffic
|
// get traffic
|
||||||
inbounds, err := t.inboundService.GetAllInbounds()
|
inbounds, err := t.inboundService.GetAllInbounds()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warning("GetAllInbounds run failed:", err)
|
logger.Warning("GetAllInbounds run failed:", err)
|
||||||
info += t.I18nBot("tgbot.answers.getInboundsFailed")
|
info.WriteString(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
||||||
} else {
|
} else {
|
||||||
// NOTE:If there no any sessions here,need to notify here
|
// NOTE:If there no any sessions here,need to notify here
|
||||||
// TODO:Sub-node push, automatic conversion format
|
// TODO:Sub-node push, automatic conversion format
|
||||||
for _, inbound := range inbounds {
|
for _, inbound := range inbounds {
|
||||||
info += t.I18nBot("tgbot.messages.inbound", "Remark=="+inbound.Remark)
|
info.WriteString(t.I18nBot("tgbot.messages.inbound", "Remark=="+inbound.Remark))
|
||||||
info += t.I18nBot("tgbot.messages.port", "Port=="+strconv.Itoa(inbound.Port))
|
info.WriteString(t.I18nBot("tgbot.messages.port", "Port=="+strconv.Itoa(inbound.Port)))
|
||||||
info += t.I18nBot("tgbot.messages.traffic", "Total=="+common.FormatTraffic((inbound.Up+inbound.Down)), "Upload=="+common.FormatTraffic(inbound.Up), "Download=="+common.FormatTraffic(inbound.Down))
|
info.WriteString(t.I18nBot("tgbot.messages.traffic", "Total=="+common.FormatTraffic((inbound.Up+inbound.Down)), "Upload=="+common.FormatTraffic(inbound.Up), "Download=="+common.FormatTraffic(inbound.Down)))
|
||||||
|
|
||||||
if inbound.ExpiryTime == 0 {
|
if inbound.ExpiryTime == 0 {
|
||||||
info += t.I18nBot("tgbot.messages.expire", "Time=="+t.I18nBot("tgbot.unlimited"))
|
info.WriteString(t.I18nBot("tgbot.messages.expire", "Time=="+t.I18nBot("tgbot.unlimited")))
|
||||||
} else {
|
} else {
|
||||||
info += t.I18nBot("tgbot.messages.expire", "Time=="+time.Unix((inbound.ExpiryTime/1000), 0).Format("2006-01-02 15:04:05"))
|
info.WriteString(t.I18nBot("tgbot.messages.expire", "Time=="+time.Unix((inbound.ExpiryTime/1000), 0).Format("2006-01-02 15:04:05")))
|
||||||
}
|
}
|
||||||
info += "\r\n"
|
info.WriteString("\r\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return info
|
return info.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// getInbounds creates an inline keyboard with all inbounds.
|
// getInbounds creates an inline keyboard with all inbounds.
|
||||||
@@ -2991,12 +3074,9 @@ func (t *Tgbot) clientInfoMsg(
|
|||||||
status := t.I18nBot("tgbot.offline")
|
status := t.I18nBot("tgbot.offline")
|
||||||
isOnline := false
|
isOnline := false
|
||||||
if p.IsRunning() {
|
if p.IsRunning() {
|
||||||
for _, online := range p.GetOnlineClients() {
|
if slices.Contains(p.GetOnlineClients(), traffic.Email) {
|
||||||
if online == traffic.Email {
|
|
||||||
status = t.I18nBot("tgbot.online")
|
status = t.I18nBot("tgbot.online")
|
||||||
isOnline = true
|
isOnline = true
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3083,9 +3163,41 @@ func (t *Tgbot) searchClientIps(chatId int64, email string, messageID ...int) {
|
|||||||
ips = t.I18nBot("tgbot.noIpRecord")
|
ips = t.I18nBot("tgbot.noIpRecord")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
formattedIps := ips
|
||||||
|
if err == nil && len(ips) > 0 {
|
||||||
|
type ipWithTimestamp struct {
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var ipsWithTime []ipWithTimestamp
|
||||||
|
if json.Unmarshal([]byte(ips), &ipsWithTime) == nil && len(ipsWithTime) > 0 {
|
||||||
|
lines := make([]string, 0, len(ipsWithTime))
|
||||||
|
for _, item := range ipsWithTime {
|
||||||
|
if item.IP == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if item.Timestamp > 0 {
|
||||||
|
ts := time.Unix(item.Timestamp, 0).Format("2006-01-02 15:04:05")
|
||||||
|
lines = append(lines, fmt.Sprintf("%s (%s)", item.IP, ts))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines = append(lines, item.IP)
|
||||||
|
}
|
||||||
|
if len(lines) > 0 {
|
||||||
|
formattedIps = strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var oldIps []string
|
||||||
|
if json.Unmarshal([]byte(ips), &oldIps) == nil && len(oldIps) > 0 {
|
||||||
|
formattedIps = strings.Join(oldIps, "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
output := ""
|
output := ""
|
||||||
output += t.I18nBot("tgbot.messages.email", "Email=="+email)
|
output += t.I18nBot("tgbot.messages.email", "Email=="+email)
|
||||||
output += t.I18nBot("tgbot.messages.ips", "IPs=="+ips)
|
output += t.I18nBot("tgbot.messages.ips", "IPs=="+formattedIps)
|
||||||
output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
|
output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
|
||||||
|
|
||||||
inlineKeyboard := tu.InlineKeyboard(
|
inlineKeyboard := tu.InlineKeyboard(
|
||||||
@@ -3203,23 +3315,9 @@ func (t *Tgbot) searchClient(chatId int64, email string, messageID ...int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// addClient handles the process of adding a new client to an inbound.
|
// getCommonClientButtons returns the shared inline keyboard rows for client configuration
|
||||||
func (t *Tgbot) addClient(chatId int64, msg string, messageID ...int) {
|
func (t *Tgbot) getCommonClientButtons() [][]telego.InlineKeyboardButton {
|
||||||
inbound, err := t.inboundService.GetInbound(receiver_inbound_ID)
|
return [][]telego.InlineKeyboardButton{
|
||||||
if err != nil {
|
|
||||||
t.SendMsgToTgbot(chatId, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
protocol := inbound.Protocol
|
|
||||||
|
|
||||||
switch protocol {
|
|
||||||
case model.VMESS, model.VLESS:
|
|
||||||
inlineKeyboard := tu.InlineKeyboard(
|
|
||||||
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.InlineKeyboardRow(
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.limitTraffic")).WithCallbackData("add_client_ch_default_traffic"),
|
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.InlineKeyboardButton(t.I18nBot("tgbot.buttons.resetExpire")).WithCallbackData("add_client_ch_default_exp"),
|
||||||
@@ -3235,68 +3333,52 @@ func (t *Tgbot) addClient(chatId int64, msg string, messageID ...int) {
|
|||||||
tu.InlineKeyboardRow(
|
tu.InlineKeyboardRow(
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData("add_client_cancel"),
|
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData("add_client_cancel"),
|
||||||
),
|
),
|
||||||
)
|
}
|
||||||
if len(messageID) > 0 {
|
}
|
||||||
t.editMessageTgBot(chatId, messageID[0], msg, inlineKeyboard)
|
|
||||||
} else {
|
// addClient handles the process of adding a new client to an inbound.
|
||||||
t.SendMsgToTgbot(chatId, msg, inlineKeyboard)
|
func (t *Tgbot) addClient(chatId int64, msg string, messageID ...int) {
|
||||||
|
inbound, err := t.inboundService.GetInbound(receiver_inbound_ID)
|
||||||
|
if err != nil {
|
||||||
|
t.SendMsgToTgbot(chatId, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol := inbound.Protocol
|
||||||
|
|
||||||
|
var protocolRows [][]telego.InlineKeyboardButton
|
||||||
|
switch protocol {
|
||||||
|
case model.VMESS, model.VLESS:
|
||||||
|
protocolRows = [][]telego.InlineKeyboardButton{
|
||||||
|
tu.InlineKeyboardRow(
|
||||||
|
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"),
|
||||||
|
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_id")).WithCallbackData("add_client_ch_default_id"),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
case model.Trojan:
|
case model.Trojan:
|
||||||
inlineKeyboard := tu.InlineKeyboard(
|
protocolRows = [][]telego.InlineKeyboardButton{
|
||||||
tu.InlineKeyboardRow(
|
tu.InlineKeyboardRow(
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"),
|
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"),
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_password")).WithCallbackData("add_client_ch_default_pass_tr"),
|
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_password")).WithCallbackData("add_client_ch_default_pass_tr"),
|
||||||
),
|
),
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.limitTraffic")).WithCallbackData("add_client_ch_default_traffic"),
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.resetExpire")).WithCallbackData("add_client_ch_default_exp"),
|
|
||||||
),
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_comment")).WithCallbackData("add_client_ch_default_comment"),
|
|
||||||
tu.InlineKeyboardButton("ip limit").WithCallbackData("add_client_ch_default_ip_limit"),
|
|
||||||
),
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitDisable")).WithCallbackData("add_client_submit_disable"),
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitEnable")).WithCallbackData("add_client_submit_enable"),
|
|
||||||
),
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData("add_client_cancel"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if len(messageID) > 0 {
|
|
||||||
t.editMessageTgBot(chatId, messageID[0], msg, inlineKeyboard)
|
|
||||||
} else {
|
|
||||||
t.SendMsgToTgbot(chatId, msg, inlineKeyboard)
|
|
||||||
}
|
}
|
||||||
case model.Shadowsocks:
|
case model.Shadowsocks:
|
||||||
inlineKeyboard := tu.InlineKeyboard(
|
protocolRows = [][]telego.InlineKeyboardButton{
|
||||||
tu.InlineKeyboardRow(
|
tu.InlineKeyboardRow(
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"),
|
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"),
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_password")).WithCallbackData("add_client_ch_default_pass_sh"),
|
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_password")).WithCallbackData("add_client_ch_default_pass_sh"),
|
||||||
),
|
),
|
||||||
tu.InlineKeyboardRow(
|
}
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.limitTraffic")).WithCallbackData("add_client_ch_default_traffic"),
|
}
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.resetExpire")).WithCallbackData("add_client_ch_default_exp"),
|
|
||||||
),
|
commonRows := t.getCommonClientButtons()
|
||||||
tu.InlineKeyboardRow(
|
inlineKeyboard := tu.InlineKeyboard(append(protocolRows, commonRows...)...)
|
||||||
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 {
|
if len(messageID) > 0 {
|
||||||
t.editMessageTgBot(chatId, messageID[0], msg, inlineKeyboard)
|
t.editMessageTgBot(chatId, messageID[0], msg, inlineKeyboard)
|
||||||
} else {
|
} else {
|
||||||
t.SendMsgToTgbot(chatId, msg, inlineKeyboard)
|
t.SendMsgToTgbot(chatId, msg, inlineKeyboard)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3329,11 +3411,11 @@ func (t *Tgbot) searchInbound(chatId int64, remark string) {
|
|||||||
t.SendMsgToTgbot(chatId, info)
|
t.SendMsgToTgbot(chatId, info)
|
||||||
|
|
||||||
if len(inbound.ClientStats) > 0 {
|
if len(inbound.ClientStats) > 0 {
|
||||||
output := ""
|
var output strings.Builder
|
||||||
for _, traffic := range inbound.ClientStats {
|
for _, traffic := range inbound.ClientStats {
|
||||||
output += t.clientInfoMsg(&traffic, true, true, true, true, true, true)
|
output.WriteString(t.clientInfoMsg(&traffic, true, true, true, true, true, true))
|
||||||
}
|
}
|
||||||
t.SendMsgToTgbot(chatId, output)
|
t.SendMsgToTgbot(chatId, output.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3563,13 +3645,17 @@ func (t *Tgbot) sendBackup(chatId int64) {
|
|||||||
logger.Error("Error in trigger a checkpoint operation: ", err)
|
logger.Error("Error in trigger a checkpoint operation: ", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send database backup
|
||||||
file, err := os.Open(config.GetDBPath())
|
file, err := os.Open(config.GetDBPath())
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
defer file.Close()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
document := tu.Document(
|
document := tu.Document(
|
||||||
tu.ID(chatId),
|
tu.ID(chatId),
|
||||||
tu.File(file),
|
tu.File(file),
|
||||||
)
|
)
|
||||||
_, err = bot.SendDocument(context.Background(), document)
|
_, err = bot.SendDocument(ctx, document)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("Error in uploading backup: ", err)
|
logger.Error("Error in uploading backup: ", err)
|
||||||
}
|
}
|
||||||
@@ -3577,13 +3663,20 @@ func (t *Tgbot) sendBackup(chatId int64) {
|
|||||||
logger.Error("Error in opening db file for backup: ", err)
|
logger.Error("Error in opening db file for backup: ", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Small delay between file sends
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
|
||||||
|
// Send config.json backup
|
||||||
file, err = os.Open(xray.GetConfigPath())
|
file, err = os.Open(xray.GetConfigPath())
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
defer file.Close()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
document := tu.Document(
|
document := tu.Document(
|
||||||
tu.ID(chatId),
|
tu.ID(chatId),
|
||||||
tu.File(file),
|
tu.File(file),
|
||||||
)
|
)
|
||||||
_, err = bot.SendDocument(context.Background(), document)
|
_, err = bot.SendDocument(ctx, document)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("Error in uploading config.json: ", err)
|
logger.Error("Error in uploading config.json: ", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-11
@@ -33,7 +33,7 @@ func (s *UserService) GetFirstUser() (*model.User, error) {
|
|||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) CheckUser(username string, password string, twoFactorCode string) *model.User {
|
func (s *UserService) CheckUser(username string, password string, twoFactorCode string) (*model.User, error) {
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
|
|
||||||
user := &model.User{}
|
user := &model.User{}
|
||||||
@@ -43,17 +43,16 @@ func (s *UserService) CheckUser(username string, password string, twoFactorCode
|
|||||||
First(user).
|
First(user).
|
||||||
Error
|
Error
|
||||||
if err == gorm.ErrRecordNotFound {
|
if err == gorm.ErrRecordNotFound {
|
||||||
return nil
|
return nil, errors.New("invalid credentials")
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
logger.Warning("check user err:", err)
|
logger.Warning("check user err:", err)
|
||||||
return nil
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// If LDAP enabled and local password check fails, attempt LDAP auth
|
|
||||||
if !crypto.CheckPasswordHash(user.Password, password) {
|
if !crypto.CheckPasswordHash(user.Password, password) {
|
||||||
ldapEnabled, _ := s.settingService.GetLdapEnable()
|
ldapEnabled, _ := s.settingService.GetLdapEnable()
|
||||||
if !ldapEnabled {
|
if !ldapEnabled {
|
||||||
return nil
|
return nil, errors.New("invalid credentials")
|
||||||
}
|
}
|
||||||
|
|
||||||
host, _ := s.settingService.GetLdapHost()
|
host, _ := s.settingService.GetLdapHost()
|
||||||
@@ -77,15 +76,14 @@ func (s *UserService) CheckUser(username string, password string, twoFactorCode
|
|||||||
}
|
}
|
||||||
ok, err := ldaputil.AuthenticateUser(cfg, username, password)
|
ok, err := ldaputil.AuthenticateUser(cfg, username, password)
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
return nil
|
return nil, errors.New("invalid credentials")
|
||||||
}
|
}
|
||||||
// On successful LDAP auth, continue 2FA checks below
|
|
||||||
}
|
}
|
||||||
|
|
||||||
twoFactorEnable, err := s.settingService.GetTwoFactorEnable()
|
twoFactorEnable, err := s.settingService.GetTwoFactorEnable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warning("check two factor err:", err)
|
logger.Warning("check two factor err:", err)
|
||||||
return nil
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if twoFactorEnable {
|
if twoFactorEnable {
|
||||||
@@ -93,15 +91,15 @@ func (s *UserService) CheckUser(username string, password string, twoFactorCode
|
|||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warning("check two factor token err:", err)
|
logger.Warning("check two factor token err:", err)
|
||||||
return nil
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if gotp.NewDefaultTOTP(twoFactorToken).Now() != twoFactorCode {
|
if gotp.NewDefaultTOTP(twoFactorToken).Now() != twoFactorCode {
|
||||||
return nil
|
return nil, errors.New("invalid 2fa code")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return user
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) UpdateUser(id int, username string, password string) error {
|
func (s *UserService) UpdateUser(id int, username string, password string) error {
|
||||||
|
|||||||
+27
-20
@@ -118,36 +118,40 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
|
|||||||
json.Unmarshal([]byte(inbound.Settings), &settings)
|
json.Unmarshal([]byte(inbound.Settings), &settings)
|
||||||
clients, ok := settings["clients"].([]any)
|
clients, ok := settings["clients"].([]any)
|
||||||
if ok {
|
if ok {
|
||||||
// check users active or not
|
// Fast O(N) lookup map for client traffic enablement
|
||||||
clientStats := inbound.ClientStats
|
clientStats := inbound.ClientStats
|
||||||
|
enableMap := make(map[string]bool, len(clientStats))
|
||||||
for _, clientTraffic := range clientStats {
|
for _, clientTraffic := range clientStats {
|
||||||
indexDecrease := 0
|
enableMap[clientTraffic.Email] = clientTraffic.Enable
|
||||||
for index, client := range clients {
|
|
||||||
c := client.(map[string]any)
|
|
||||||
if c["email"] == clientTraffic.Email {
|
|
||||||
if !clientTraffic.Enable {
|
|
||||||
clients = RemoveIndex(clients, index-indexDecrease)
|
|
||||||
indexDecrease++
|
|
||||||
logger.Infof("Remove Inbound User %s due to expiration or traffic limit", c["email"])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// filter and clean clients
|
||||||
|
var final_clients []any
|
||||||
|
for _, client := range clients {
|
||||||
|
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
|
// clear client config for additional parameters
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for key := range c {
|
for key := range c {
|
||||||
if key != "email" && key != "id" && key != "password" && key != "flow" && key != "method" {
|
if key != "email" && key != "id" && key != "password" && key != "flow" && key != "method" && key != "auth" {
|
||||||
delete(c, key)
|
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"
|
c["flow"] = "xtls-rprx-vision"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -202,7 +206,10 @@ func (s *XrayService) GetXrayTraffic() ([]*xray.Traffic, []*xray.ClientTraffic,
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
apiPort := p.GetAPIPort()
|
apiPort := p.GetAPIPort()
|
||||||
s.xrayAPI.Init(apiPort)
|
if err := s.xrayAPI.Init(apiPort); err != nil {
|
||||||
|
logger.Debug("Failed to initialize Xray API:", err)
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
defer s.xrayAPI.Close()
|
defer s.xrayAPI.Close()
|
||||||
|
|
||||||
traffic, clientTraffic, err := s.xrayAPI.GetTraffic(true)
|
traffic, clientTraffic, err := s.xrayAPI.GetTraffic(true)
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ type XraySettingService struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *XraySettingService) SaveXraySetting(newXraySettings string) error {
|
func (s *XraySettingService) SaveXraySetting(newXraySettings string) error {
|
||||||
|
// The frontend round-trips the whole getXraySetting response back
|
||||||
|
// through the textarea, so if it has ever received a wrapped
|
||||||
|
// payload (see UnwrapXrayTemplateConfig) it sends that same wrapper
|
||||||
|
// back here. Strip it before validation/storage, otherwise we save
|
||||||
|
// garbage the next read can't recover from without this same call.
|
||||||
|
newXraySettings = UnwrapXrayTemplateConfig(newXraySettings)
|
||||||
if err := s.CheckXrayConfig(newXraySettings); err != nil {
|
if err := s.CheckXrayConfig(newXraySettings); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -29,3 +35,51 @@ func (s *XraySettingService) CheckXrayConfig(XrayTemplateConfig string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UnwrapXrayTemplateConfig returns the raw xray config JSON from `raw`,
|
||||||
|
// peeling off any number of `{ "inboundTags": ..., "outboundTestUrl": ...,
|
||||||
|
// "xraySetting": <real config> }` response-shaped wrappers that may have
|
||||||
|
// ended up in the database.
|
||||||
|
//
|
||||||
|
// How it got there: getXraySetting used to embed the raw DB value as
|
||||||
|
// `xraySetting` in its response without checking whether the stored
|
||||||
|
// value was already that exact response shape. If the frontend then
|
||||||
|
// saved it verbatim (the textarea is a round-trip of the JSON it was
|
||||||
|
// handed), the wrapper got persisted — and each subsequent save nested
|
||||||
|
// another layer, producing the blank Xray Settings page reported in
|
||||||
|
// issue #4059.
|
||||||
|
//
|
||||||
|
// If `raw` does not look like a wrapper, it is returned unchanged.
|
||||||
|
func UnwrapXrayTemplateConfig(raw string) string {
|
||||||
|
const maxDepth = 8 // defensive cap against pathological multi-nest values
|
||||||
|
for i := 0; i < maxDepth; i++ {
|
||||||
|
var top map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal([]byte(raw), &top); err != nil {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
inner, ok := top["xraySetting"]
|
||||||
|
if !ok {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
// Real xray configs never contain a top-level "xraySetting" key,
|
||||||
|
// but they do contain things like "inbounds"/"outbounds"/"api".
|
||||||
|
// If any of those are present, we're already at the real config
|
||||||
|
// and the "xraySetting" field is either user data or coincidence
|
||||||
|
// — don't touch it.
|
||||||
|
for _, k := range []string{"inbounds", "outbounds", "routing", "api", "dns", "log", "policy", "stats"} {
|
||||||
|
if _, hit := top[k]; hit {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Peel off one layer.
|
||||||
|
unwrapped := string(inner)
|
||||||
|
// `xraySetting` may be stored either as a JSON object or as a
|
||||||
|
// JSON-encoded string of an object. Handle both.
|
||||||
|
var asStr string
|
||||||
|
if err := json.Unmarshal(inner, &asStr); err == nil {
|
||||||
|
unwrapped = asStr
|
||||||
|
}
|
||||||
|
raw = unwrapped
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user