mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-02-13 13:57:59 +00:00
Compare commits
20 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5c0fe3edf | ||
|
|
f4057989f5 | ||
|
|
84013b0b3f | ||
|
|
511adffc5b | ||
|
|
fc6344b840 | ||
|
|
b3555ce1b8 | ||
|
|
c2f409c3c4 | ||
|
|
0994f8756f | ||
|
|
4779939424 | ||
|
|
4a455aa532 | ||
|
|
25f64738e4 | ||
|
|
5bb87fd3d4 | ||
|
|
491e3f9f8b | ||
|
|
d8fb09faae | ||
|
|
f87c68ea68 | ||
|
|
687e8cf1ba | ||
|
|
03f04194f2 | ||
|
|
248700a8a3 | ||
|
|
ff128a7275 | ||
|
|
e8d2973be7 |
48 changed files with 2150 additions and 850 deletions
155
.github/copilot-instructions.md
vendored
Normal file
155
.github/copilot-instructions.md
vendored
Normal file
|
|
@ -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
|
||||
31
.github/workflows/cleanup_caches.yml
vendored
Normal file
31
.github/workflows/cleanup_caches.yml
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
name: Cleanup Caches
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 3 * * 0' # every Sunday
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
steps:
|
||||
- name: Delete caches older than 3 days
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
CUTOFF_DATE=$(date -d "3 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
|
||||
39
.github/workflows/release.yml
vendored
39
.github/workflows/release.yml
vendored
|
|
@ -89,7 +89,7 @@ jobs:
|
|||
cd x-ui/bin
|
||||
|
||||
# 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.2.6/"
|
||||
if [ "${{ matrix.platform }}" == "amd64" ]; then
|
||||
wget -q ${Xray_URL}Xray-linux-64.zip
|
||||
unzip Xray-linux-64.zip
|
||||
|
|
@ -173,21 +173,42 @@ jobs:
|
|||
go-version-file: go.mod
|
||||
check-latest: true
|
||||
|
||||
- name: Build 3X-UI for Windows
|
||||
- name: Install MSYS2
|
||||
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: |
|
||||
export PATH="/c/hostedtoolcache/windows/go/$(ls /c/hostedtoolcache/windows/go | sort -V | tail -n1)/x64/bin:$PATH"
|
||||
|
||||
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
|
||||
|
||||
- name: Copy and download resources
|
||||
shell: pwsh
|
||||
run: |
|
||||
$env:CGO_ENABLED="1"
|
||||
$env:GOOS="windows"
|
||||
$env:GOARCH="amd64"
|
||||
go build -ldflags "-w -s" -o xui-release.exe -v main.go
|
||||
|
||||
mkdir x-ui
|
||||
Copy-Item xui-release.exe x-ui\
|
||||
Copy-Item xui-release.exe x-ui\x-ui.exe
|
||||
mkdir x-ui\bin
|
||||
cd x-ui\bin
|
||||
|
||||
# Download Xray for Windows
|
||||
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.1.31/"
|
||||
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.2.6/"
|
||||
Invoke-WebRequest -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
|
||||
Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
|
||||
Remove-Item "Xray-windows-64.zip"
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ case $1 in
|
|||
esac
|
||||
mkdir -p build/bin
|
||||
cd build/bin
|
||||
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.1.31/Xray-linux-${ARCH}.zip"
|
||||
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.2.6/Xray-linux-${ARCH}.zip"
|
||||
unzip "Xray-linux-${ARCH}.zip"
|
||||
rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
|
||||
mv xray "xray-linux-${FNAME}"
|
||||
|
|
|
|||
|
|
@ -30,7 +30,8 @@ RUN apk add --no-cache --update \
|
|||
tzdata \
|
||||
fail2ban \
|
||||
bash \
|
||||
curl
|
||||
curl \
|
||||
openssl
|
||||
|
||||
COPY --from=builder /app/build/ /app/
|
||||
COPY --from=builder /app/DockerEntrypoint.sh /app/
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
2.8.9
|
||||
2.8.10
|
||||
32
go.mod
32
go.mod
|
|
@ -1,6 +1,6 @@
|
|||
module github.com/mhsanaei/3x-ui/v2
|
||||
|
||||
go 1.25.6
|
||||
go 1.25.7
|
||||
|
||||
require (
|
||||
github.com/gin-contrib/gzip v1.2.5
|
||||
|
|
@ -11,7 +11,7 @@ require (
|
|||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/mymmrac/telego v1.5.0
|
||||
github.com/mymmrac/telego v1.6.0
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1
|
||||
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
|
||||
github.com/pelletier/go-toml/v2 v2.2.4
|
||||
|
|
@ -20,11 +20,11 @@ require (
|
|||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/valyala/fasthttp v1.69.0
|
||||
github.com/xlzd/gotp v0.1.0
|
||||
github.com/xtls/xray-core v1.260131.0
|
||||
github.com/xtls/xray-core v1.260206.0
|
||||
go.uber.org/atomic v1.11.0
|
||||
golang.org/x/crypto v0.47.0
|
||||
golang.org/x/sys v0.40.0
|
||||
golang.org/x/text v0.33.0
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/sys v0.41.0
|
||||
golang.org/x/text v0.34.0
|
||||
google.golang.org/grpc v1.78.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
|
|
@ -40,7 +40,7 @@ require (
|
|||
github.com/cloudflare/circl v1.6.3 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/ebitengine/purego v0.9.1 // 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/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
|
|
@ -57,16 +57,16 @@ require (
|
|||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/juju/ratelimit v1.0.2 // indirect
|
||||
github.com/klauspost/compress v1.18.3 // indirect
|
||||
github.com/klauspost/compress v1.18.4 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.33 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.34 // indirect
|
||||
github.com/miekg/dns v1.1.72 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pires/go-proxyproto v0.9.2 // indirect
|
||||
github.com/pires/go-proxyproto v0.11.0 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
|
|
@ -85,16 +85,16 @@ require (
|
|||
github.com/xtls/reality v0.0.0-20251116175510-cd53f7d50237 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
||||
golang.org/x/arch v0.23.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
|
||||
golang.org/x/mod v0.32.0 // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
golang.org/x/arch v0.24.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260209203927-2842357ff358 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
|
||||
lukechampine.com/blake3 v1.4.1 // indirect
|
||||
|
|
|
|||
60
go.sum
60
go.sum
|
|
@ -23,8 +23,8 @@ 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/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
|
||||
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344 h1:Arcl6UOIS/kgO2nW3A65HN+7CMjSDP/gofXL4CZt1V4=
|
||||
github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344/go.mod h1:GIjDIg/heH5DOkXY3YJ/wNhfHsQHoXGjl8G8amsYQ1I=
|
||||
github.com/gin-contrib/gzip v1.2.5 h1:fIZs0S+l17pIu1P5XRJOo/YNqfIuPCrZZ3TWB7pjckI=
|
||||
|
|
@ -105,8 +105,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
|
|||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/juju/ratelimit v1.0.2 h1:sRxmtRiajbvrcLQT7S+JbqU0ntsb9W2yhSdNN8tWfaI=
|
||||
github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk=
|
||||
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
|
||||
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
|
|
@ -119,8 +119,8 @@ github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIi
|
|||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
|
||||
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
|
|
@ -128,8 +128,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
|||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/mymmrac/telego v1.5.0 h1:VjBDZcSpEQim1Y3JX2WCsF/PJqOA2DKfZknXUvtKCnw=
|
||||
github.com/mymmrac/telego v1.5.0/go.mod h1:MDYHIeT68tURdcwH4SNCQQ+0xBC3u6wOcH2hBpa4Ip0=
|
||||
github.com/mymmrac/telego v1.6.0 h1:Zc8rgyHozvd/7ZgyrigyHdAF9koHYMfilYfyB6wlFC0=
|
||||
github.com/mymmrac/telego v1.6.0/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y=
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
|
||||
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88=
|
||||
|
|
@ -138,8 +138,8 @@ github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3v
|
|||
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pires/go-proxyproto v0.9.2 h1:H1UdHn695zUVVmB0lQ354lOWHOy6TZSpzBl3tgN0s1U=
|
||||
github.com/pires/go-proxyproto v0.9.2/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
|
||||
github.com/pires/go-proxyproto v0.11.0 h1:gUQpS85X/VJMdUsYyEgyn59uLJvGqPhJV5YvG68wXH4=
|
||||
github.com/pires/go-proxyproto v0.11.0/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
|
|
@ -195,8 +195,8 @@ github.com/xlzd/gotp v0.1.0 h1:37blvlKCh38s+fkem+fFh7sMnceltoIEBYTVXyoa5Po=
|
|||
github.com/xlzd/gotp v0.1.0/go.mod h1:ndLJ3JKzi3xLmUProq4LLxCuECL93dG9WASNLpHz8qg=
|
||||
github.com/xtls/reality v0.0.0-20251116175510-cd53f7d50237 h1:UXjrmniKlY+ZbIqpN91lejB3pszQQQRVu1vqH/p/aGM=
|
||||
github.com/xtls/reality v0.0.0-20251116175510-cd53f7d50237/go.mod h1:vbHCV/3VWUvy1oKvTxxWJRPEWSeR1sYgQHIh6u/JiZQ=
|
||||
github.com/xtls/xray-core v1.260131.0 h1:gPBykLhUvRZ8sfubNerkwWqV3c15UtmSYQG2cgKqrV4=
|
||||
github.com/xtls/xray-core v1.260131.0/go.mod h1:cxzYFZrxu1B1NtPjHsqv4UzgDvRA71mV4rXYH4KtO7Q=
|
||||
github.com/xtls/xray-core v1.260206.0 h1:gY8IV6u76CW93txL9QmacgZ0Udxr2Q3e9qUxXAhdHqI=
|
||||
github.com/xtls/xray-core v1.260206.0/go.mod h1:GyFIgVGRJkt3eyV/NMcdxOKXcJPqGGpyupHzy16uJhU=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
|
|
@ -221,16 +221,16 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
|||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
||||
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
|
||||
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
|
||||
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20260209203927-2842357ff358 h1:kpfSV7uLwKJbFSEgNhWzGSL47NDSF/5pYYQw1V0ub6c=
|
||||
golang.org/x/exp v0.0.0-20260209203927-2842357ff358/go.mod h1:R3t0oliuryB5eenPWl3rrQxwnNM3WTwnsRZZiXLAAW8=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
|
|
@ -239,22 +239,22 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
|
||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ=
|
||||
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-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
|
|
|
|||
|
|
@ -654,8 +654,11 @@ config_after_install() {
|
|||
)
|
||||
local server_ip=""
|
||||
for ip_address in "${URL_lists[@]}"; do
|
||||
server_ip=$(curl -s --max-time 3 "${ip_address}" 2>/dev/null | tr -d '[:space:]')
|
||||
if [[ -n "${server_ip}" ]]; then
|
||||
local response=$(curl -s -w "\n%{http_code}" --max-time 3 "${ip_address}" 2>/dev/null)
|
||||
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
|
||||
fi
|
||||
done
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ package sub
|
|||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v2/config"
|
||||
|
||||
|
|
@ -64,8 +64,8 @@ func NewSUBController(
|
|||
subEncrypt: encrypt,
|
||||
updateInterval: update,
|
||||
|
||||
subService: sub,
|
||||
subJsonService: NewSubJsonService(jsonFragment, jsonNoise, jsonMux, jsonRules, sub),
|
||||
subService: sub,
|
||||
subJsonService: NewSubJsonService(jsonFragment, jsonNoise, jsonMux, jsonRules, sub),
|
||||
}
|
||||
a.initRouter(g)
|
||||
return a
|
||||
|
|
@ -143,7 +143,11 @@ func (a *SUBController) subs(c *gin.Context) {
|
|||
|
||||
// Add headers
|
||||
header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
|
||||
a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.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 {
|
||||
c.String(200, base64.StdEncoding.EncodeToString([]byte(result)))
|
||||
|
|
@ -156,13 +160,17 @@ func (a *SUBController) subs(c *gin.Context) {
|
|||
// subJsons handles HTTP requests for JSON subscription configurations.
|
||||
func (a *SUBController) subJsons(c *gin.Context) {
|
||||
subId := c.Param("subid")
|
||||
_, host, _, _ := a.subService.ResolveRequest(c)
|
||||
scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
|
||||
jsonSub, header, err := a.subJsonService.GetJson(subId, host)
|
||||
if err != nil || len(jsonSub) == 0 {
|
||||
c.String(400, "Error!")
|
||||
} else {
|
||||
// Add headers
|
||||
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)
|
||||
|
||||
c.String(200, jsonSub)
|
||||
}
|
||||
|
|
@ -170,22 +178,36 @@ func (a *SUBController) subJsons(c *gin.Context) {
|
|||
|
||||
// ApplyCommonHeaders sets common HTTP headers for subscription responses including user info, update interval, and profile title.
|
||||
func (a *SUBController) ApplyCommonHeaders(
|
||||
c *gin.Context,
|
||||
header,
|
||||
updateInterval,
|
||||
profileTitle string,
|
||||
c *gin.Context,
|
||||
header,
|
||||
updateInterval,
|
||||
profileTitle string,
|
||||
profileSupportUrl string,
|
||||
profileUrl string,
|
||||
profileAnnounce string,
|
||||
profileUrl string,
|
||||
profileAnnounce string,
|
||||
profileEnableRouting bool,
|
||||
profileRoutingRules string,
|
||||
) {
|
||||
c.Writer.Header().Set("Subscription-Userinfo", header)
|
||||
c.Writer.Header().Set("Profile-Update-Interval", updateInterval)
|
||||
c.Writer.Header().Set("Profile-Title", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileTitle)))
|
||||
c.Writer.Header().Set("Support-Url", profileSupportUrl)
|
||||
c.Writer.Header().Set("Profile-Web-Page-Url", profileUrl)
|
||||
c.Writer.Header().Set("Announce", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileAnnounce)))
|
||||
|
||||
//Basics
|
||||
if profileTitle != "" {
|
||||
c.Writer.Header().Set("Profile-Title", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileTitle)))
|
||||
}
|
||||
if profileSupportUrl != "" {
|
||||
c.Writer.Header().Set("Support-Url", profileSupportUrl)
|
||||
}
|
||||
if profileUrl != "" {
|
||||
c.Writer.Header().Set("Profile-Web-Page-Url", profileUrl)
|
||||
}
|
||||
if 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", profileRoutingRules)
|
||||
if profileRoutingRules != "" {
|
||||
c.Writer.Header().Set("Routing", profileRoutingRules)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -253,9 +253,6 @@ func (s *SubJsonService) tlsData(tData map[string]any) map[string]any {
|
|||
|
||||
tlsData["serverName"] = tData["serverName"]
|
||||
tlsData["alpn"] = tData["alpn"]
|
||||
if allowInsecure, ok := tlsClientSettings["allowInsecure"].(bool); ok {
|
||||
tlsData["allowInsecure"] = allowInsecure
|
||||
}
|
||||
if fingerprint, ok := tlsClientSettings["fingerprint"].(string); ok {
|
||||
tlsData["fingerprint"] = fingerprint
|
||||
}
|
||||
|
|
|
|||
|
|
@ -270,9 +270,6 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
|
|||
if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok {
|
||||
obj["fp"], _ = fpValue.(string)
|
||||
}
|
||||
if insecure, ok := searchKey(tlsSettings, "allowInsecure"); ok {
|
||||
obj["allowInsecure"], _ = insecure.(bool)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -296,7 +293,7 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
|
|||
newSecurity, _ := ep["forceTls"].(string)
|
||||
newObj := map[string]any{}
|
||||
for key, value := range obj {
|
||||
if !(newSecurity == "none" && (key == "alpn" || key == "sni" || key == "fp" || key == "allowInsecure")) {
|
||||
if !(newSecurity == "none" && (key == "alpn" || key == "sni" || key == "fp")) {
|
||||
newObj[key] = value
|
||||
}
|
||||
}
|
||||
|
|
@ -431,11 +428,6 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
|
|||
if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok {
|
||||
params["fp"], _ = fpValue.(string)
|
||||
}
|
||||
if insecure, ok := searchKey(tlsSettings, "allowInsecure"); ok {
|
||||
if insecure.(bool) {
|
||||
params["allowInsecure"] = "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if streamNetwork == "tcp" && len(clients[clientIndex].Flow) > 0 {
|
||||
|
|
@ -501,7 +493,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
|
|||
q := url.Query()
|
||||
|
||||
for k, v := range params {
|
||||
if !(newSecurity == "none" && (k == "alpn" || k == "sni" || k == "fp" || k == "allowInsecure")) {
|
||||
if !(newSecurity == "none" && (k == "alpn" || k == "sni" || k == "fp")) {
|
||||
q.Add(k, v)
|
||||
}
|
||||
}
|
||||
|
|
@ -632,11 +624,6 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
|
|||
if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok {
|
||||
params["fp"], _ = fpValue.(string)
|
||||
}
|
||||
if insecure, ok := searchKey(tlsSettings, "allowInsecure"); ok {
|
||||
if insecure.(bool) {
|
||||
params["allowInsecure"] = "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -698,7 +685,7 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
|
|||
q := url.Query()
|
||||
|
||||
for k, v := range params {
|
||||
if !(newSecurity == "none" && (k == "alpn" || k == "sni" || k == "fp" || k == "allowInsecure")) {
|
||||
if !(newSecurity == "none" && (k == "alpn" || k == "sni" || k == "fp")) {
|
||||
q.Add(k, v)
|
||||
}
|
||||
}
|
||||
|
|
@ -837,11 +824,6 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
|
|||
if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok {
|
||||
params["fp"], _ = fpValue.(string)
|
||||
}
|
||||
if insecure, ok := searchKey(tlsSettings, "allowInsecure"); ok {
|
||||
if insecure.(bool) {
|
||||
params["allowInsecure"] = "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -870,7 +852,7 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
|
|||
q := url.Query()
|
||||
|
||||
for k, v := range params {
|
||||
if !(newSecurity == "none" && (k == "alpn" || k == "sni" || k == "fp" || k == "allowInsecure")) {
|
||||
if !(newSecurity == "none" && (k == "alpn" || k == "sni" || k == "fp")) {
|
||||
q.Add(k, v)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -687,8 +687,11 @@ config_after_update() {
|
|||
)
|
||||
local server_ip=""
|
||||
for ip_address in "${URL_lists[@]}"; do
|
||||
server_ip=$(${curl_bin} -s --max-time 3 "${ip_address}" 2>/dev/null | tr -d '[:space:]')
|
||||
if [[ -n "${server_ip}" ]]; then
|
||||
local response=$(curl -s -w "\n%{http_code}" --max-time 3 "${ip_address}" 2>/dev/null)
|
||||
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
|
||||
fi
|
||||
done
|
||||
|
|
|
|||
|
|
@ -635,7 +635,7 @@ class TlsStreamSettings extends XrayCommonClass {
|
|||
}
|
||||
|
||||
if (!ObjectUtil.isEmpty(json.settings)) {
|
||||
settings = new TlsStreamSettings.Settings(json.settings.allowInsecure, json.settings.fingerprint, json.settings.echConfigList);
|
||||
settings = new TlsStreamSettings.Settings(json.settings.fingerprint, json.settings.echConfigList);
|
||||
}
|
||||
return new TlsStreamSettings(
|
||||
json.serverName,
|
||||
|
|
@ -738,25 +738,21 @@ TlsStreamSettings.Cert = class extends XrayCommonClass {
|
|||
|
||||
TlsStreamSettings.Settings = class extends XrayCommonClass {
|
||||
constructor(
|
||||
allowInsecure = false,
|
||||
fingerprint = UTLS_FINGERPRINT.UTLS_CHROME,
|
||||
echConfigList = '',
|
||||
) {
|
||||
super();
|
||||
this.allowInsecure = allowInsecure;
|
||||
this.fingerprint = fingerprint;
|
||||
this.echConfigList = echConfigList;
|
||||
}
|
||||
static fromJson(json = {}) {
|
||||
return new TlsStreamSettings.Settings(
|
||||
json.allowInsecure,
|
||||
json.fingerprint,
|
||||
json.echConfigList,
|
||||
);
|
||||
}
|
||||
toJson() {
|
||||
return {
|
||||
allowInsecure: this.allowInsecure,
|
||||
fingerprint: this.fingerprint,
|
||||
echConfigList: this.echConfigList
|
||||
};
|
||||
|
|
@ -967,7 +963,7 @@ class SockoptStreamSettings extends XrayCommonClass {
|
|||
}
|
||||
}
|
||||
|
||||
class FinalMask extends XrayCommonClass {
|
||||
class UdpMask extends XrayCommonClass {
|
||||
constructor(type = 'salamander', settings = {}) {
|
||||
super();
|
||||
this.type = type;
|
||||
|
|
@ -982,6 +978,8 @@ class FinalMask extends XrayCommonClass {
|
|||
case 'header-dns':
|
||||
case 'xdns':
|
||||
return { domain: settings.domain || '' };
|
||||
case 'xicmp':
|
||||
return { ip: settings.ip || '', id: settings.id ?? 0 };
|
||||
case 'mkcp-original':
|
||||
case 'header-dtls':
|
||||
case 'header-srtp':
|
||||
|
|
@ -995,20 +993,35 @@ class FinalMask extends XrayCommonClass {
|
|||
}
|
||||
|
||||
static fromJson(json = {}) {
|
||||
return new FinalMask(
|
||||
return new UdpMask(
|
||||
json.type || 'salamander',
|
||||
json.settings || {}
|
||||
);
|
||||
}
|
||||
|
||||
toJson() {
|
||||
const result = {
|
||||
type: this.type
|
||||
return {
|
||||
type: this.type,
|
||||
settings: (this.settings && Object.keys(this.settings).length > 0) ? this.settings : undefined
|
||||
};
|
||||
if (this.settings && Object.keys(this.settings).length > 0) {
|
||||
result.settings = this.settings;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
class FinalMaskStreamSettings extends XrayCommonClass {
|
||||
constructor(udp = []) {
|
||||
super();
|
||||
this.udp = Array.isArray(udp) ? udp.map(u => new UdpMask(u.type, u.settings)) : [new UdpMask(udp.type, udp.settings)];
|
||||
}
|
||||
|
||||
static fromJson(json = {}) {
|
||||
return new FinalMaskStreamSettings(json.udp || []);
|
||||
}
|
||||
|
||||
toJson() {
|
||||
return {
|
||||
udp: this.udp.map(udp => udp.toJson())
|
||||
};
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1024,7 +1037,7 @@ class StreamSettings extends XrayCommonClass {
|
|||
grpcSettings = new GrpcStreamSettings(),
|
||||
httpupgradeSettings = new HTTPUpgradeStreamSettings(),
|
||||
xhttpSettings = new xHTTPStreamSettings(),
|
||||
finalmask = { udp: [] },
|
||||
finalmask = new FinalMaskStreamSettings(),
|
||||
sockopt = undefined,
|
||||
) {
|
||||
super();
|
||||
|
|
@ -1044,10 +1057,7 @@ class StreamSettings extends XrayCommonClass {
|
|||
}
|
||||
|
||||
addUdpMask(type = 'salamander') {
|
||||
if (!this.finalmask.udp) {
|
||||
this.finalmask.udp = [];
|
||||
}
|
||||
this.finalmask.udp.push(new FinalMask(type));
|
||||
this.finalmask.udp.push(new UdpMask(type));
|
||||
}
|
||||
|
||||
delUdpMask(index) {
|
||||
|
|
@ -1056,6 +1066,10 @@ class StreamSettings extends XrayCommonClass {
|
|||
}
|
||||
}
|
||||
|
||||
get hasFinalMask() {
|
||||
return this.finalmask.udp && this.finalmask.udp.length > 0;
|
||||
}
|
||||
|
||||
get isTls() {
|
||||
return this.security === "tls";
|
||||
}
|
||||
|
|
@ -1090,14 +1104,6 @@ class StreamSettings extends XrayCommonClass {
|
|||
}
|
||||
|
||||
static fromJson(json = {}) {
|
||||
let finalmask = { udp: [] };
|
||||
if (json.finalmask) {
|
||||
if (Array.isArray(json.finalmask)) {
|
||||
finalmask.udp = json.finalmask.map(mask => FinalMask.fromJson(mask));
|
||||
} else if (json.finalmask.udp) {
|
||||
finalmask.udp = json.finalmask.udp.map(mask => FinalMask.fromJson(mask));
|
||||
}
|
||||
}
|
||||
return new StreamSettings(
|
||||
json.network,
|
||||
json.security,
|
||||
|
|
@ -1110,7 +1116,7 @@ class StreamSettings extends XrayCommonClass {
|
|||
GrpcStreamSettings.fromJson(json.grpcSettings),
|
||||
HTTPUpgradeStreamSettings.fromJson(json.httpupgradeSettings),
|
||||
xHTTPStreamSettings.fromJson(json.xhttpSettings),
|
||||
finalmask,
|
||||
FinalMaskStreamSettings.fromJson(json.finalmask),
|
||||
SockoptStreamSettings.fromJson(json.sockopt),
|
||||
);
|
||||
}
|
||||
|
|
@ -1129,9 +1135,7 @@ class StreamSettings extends XrayCommonClass {
|
|||
grpcSettings: network === 'grpc' ? this.grpc.toJson() : undefined,
|
||||
httpupgradeSettings: network === 'httpupgrade' ? this.httpupgrade.toJson() : undefined,
|
||||
xhttpSettings: network === 'xhttp' ? this.xhttp.toJson() : undefined,
|
||||
finalmask: (this.finalmask.udp && this.finalmask.udp.length > 0) ? {
|
||||
udp: this.finalmask.udp.map(mask => mask.toJson())
|
||||
} : undefined,
|
||||
finalmask: this.hasFinalMask ? this.finalmask.toJson() : undefined,
|
||||
sockopt: this.sockopt != undefined ? this.sockopt.toJson() : undefined,
|
||||
};
|
||||
}
|
||||
|
|
@ -1302,14 +1306,6 @@ class Inbound extends XrayCommonClass {
|
|||
return null;
|
||||
}
|
||||
|
||||
get kcpType() {
|
||||
return this.stream.kcp.type;
|
||||
}
|
||||
|
||||
get kcpSeed() {
|
||||
return this.stream.kcp.seed;
|
||||
}
|
||||
|
||||
get serviceName() {
|
||||
return this.stream.grpc.serviceName;
|
||||
}
|
||||
|
|
@ -1386,8 +1382,6 @@ class Inbound extends XrayCommonClass {
|
|||
}
|
||||
} else if (network === 'kcp') {
|
||||
const kcp = this.stream.kcp;
|
||||
obj.type = kcp.type;
|
||||
obj.path = kcp.seed;
|
||||
} else if (network === 'ws') {
|
||||
const ws = this.stream.ws;
|
||||
obj.path = ws.path;
|
||||
|
|
@ -1419,9 +1413,6 @@ class Inbound extends XrayCommonClass {
|
|||
if (this.stream.tls.alpn.length > 0) {
|
||||
obj.alpn = this.stream.tls.alpn.join(',');
|
||||
}
|
||||
if (this.stream.tls.settings.allowInsecure) {
|
||||
obj.allowInsecure = this.stream.tls.settings.allowInsecure;
|
||||
}
|
||||
}
|
||||
|
||||
return 'vmess://' + Base64.encode(JSON.stringify(obj, null, 2));
|
||||
|
|
@ -1450,8 +1441,6 @@ class Inbound extends XrayCommonClass {
|
|||
break;
|
||||
case "kcp":
|
||||
const kcp = this.stream.kcp;
|
||||
params.set("headerType", kcp.type);
|
||||
params.set("seed", kcp.seed);
|
||||
break;
|
||||
case "ws":
|
||||
const ws = this.stream.ws;
|
||||
|
|
@ -1484,9 +1473,6 @@ class Inbound extends XrayCommonClass {
|
|||
if (this.stream.isTls) {
|
||||
params.set("fp", this.stream.tls.settings.fingerprint);
|
||||
params.set("alpn", this.stream.tls.alpn);
|
||||
if (this.stream.tls.settings.allowInsecure) {
|
||||
params.set("allowInsecure", "1");
|
||||
}
|
||||
if (!ObjectUtil.isEmpty(this.stream.tls.sni)) {
|
||||
params.set("sni", this.stream.tls.sni);
|
||||
}
|
||||
|
|
@ -1555,8 +1541,6 @@ class Inbound extends XrayCommonClass {
|
|||
break;
|
||||
case "kcp":
|
||||
const kcp = this.stream.kcp;
|
||||
params.set("headerType", kcp.type);
|
||||
params.set("seed", kcp.seed);
|
||||
break;
|
||||
case "ws":
|
||||
const ws = this.stream.ws;
|
||||
|
|
@ -1589,9 +1573,6 @@ class Inbound extends XrayCommonClass {
|
|||
if (this.stream.isTls) {
|
||||
params.set("fp", this.stream.tls.settings.fingerprint);
|
||||
params.set("alpn", this.stream.tls.alpn);
|
||||
if (this.stream.tls.settings.allowInsecure) {
|
||||
params.set("allowInsecure", "1");
|
||||
}
|
||||
if (this.stream.tls.settings.echConfigList?.length > 0) {
|
||||
params.set("ech", this.stream.tls.settings.echConfigList);
|
||||
}
|
||||
|
|
@ -1636,8 +1617,6 @@ class Inbound extends XrayCommonClass {
|
|||
break;
|
||||
case "kcp":
|
||||
const kcp = this.stream.kcp;
|
||||
params.set("headerType", kcp.type);
|
||||
params.set("seed", kcp.seed);
|
||||
break;
|
||||
case "ws":
|
||||
const ws = this.stream.ws;
|
||||
|
|
@ -1670,9 +1649,6 @@ class Inbound extends XrayCommonClass {
|
|||
if (this.stream.isTls) {
|
||||
params.set("fp", this.stream.tls.settings.fingerprint);
|
||||
params.set("alpn", this.stream.tls.alpn);
|
||||
if (this.stream.tls.settings.allowInsecure) {
|
||||
params.set("allowInsecure", "1");
|
||||
}
|
||||
if (this.stream.tls.settings.echConfigList?.length > 0) {
|
||||
params.set("ech", this.stream.tls.settings.echConfigList);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -345,16 +345,14 @@ class TlsStreamSettings extends CommonClass {
|
|||
serverName = '',
|
||||
alpn = [],
|
||||
fingerprint = '',
|
||||
allowInsecure = false,
|
||||
echConfigList = '',
|
||||
verifyPeerCertByName = 'cloudflare-dns.com',
|
||||
verifyPeerCertByName = '',
|
||||
pinnedPeerCertSha256 = '',
|
||||
) {
|
||||
super();
|
||||
this.serverName = serverName;
|
||||
this.alpn = alpn;
|
||||
this.fingerprint = fingerprint;
|
||||
this.allowInsecure = allowInsecure;
|
||||
this.echConfigList = echConfigList;
|
||||
this.verifyPeerCertByName = verifyPeerCertByName;
|
||||
this.pinnedPeerCertSha256 = pinnedPeerCertSha256;
|
||||
|
|
@ -365,7 +363,6 @@ class TlsStreamSettings extends CommonClass {
|
|||
json.serverName,
|
||||
json.alpn,
|
||||
json.fingerprint,
|
||||
json.allowInsecure,
|
||||
json.echConfigList,
|
||||
json.verifyPeerCertByName,
|
||||
json.pinnedPeerCertSha256,
|
||||
|
|
@ -377,7 +374,6 @@ class TlsStreamSettings extends CommonClass {
|
|||
serverName: this.serverName,
|
||||
alpn: this.alpn,
|
||||
fingerprint: this.fingerprint,
|
||||
allowInsecure: this.allowInsecure,
|
||||
echConfigList: this.echConfigList,
|
||||
verifyPeerCertByName: this.verifyPeerCertByName,
|
||||
pinnedPeerCertSha256: this.pinnedPeerCertSha256
|
||||
|
|
@ -568,7 +564,7 @@ class SockoptStreamSettings extends CommonClass {
|
|||
}
|
||||
}
|
||||
|
||||
class FinalMask extends CommonClass {
|
||||
class UdpMask extends CommonClass {
|
||||
constructor(type = 'salamander', settings = {}) {
|
||||
super();
|
||||
this.type = type;
|
||||
|
|
@ -596,21 +592,35 @@ class FinalMask extends CommonClass {
|
|||
}
|
||||
|
||||
static fromJson(json = {}) {
|
||||
return new FinalMask(
|
||||
return new UdpMask(
|
||||
json.type || 'salamander',
|
||||
json.settings || {}
|
||||
);
|
||||
}
|
||||
|
||||
toJson() {
|
||||
const result = {
|
||||
type: this.type
|
||||
return {
|
||||
type: this.type,
|
||||
settings: (this.settings && Object.keys(this.settings).length > 0) ? this.settings : undefined
|
||||
};
|
||||
// Only include settings if they exist and are not empty
|
||||
if (this.settings && Object.keys(this.settings).length > 0) {
|
||||
result.settings = this.settings;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
class FinalMaskStreamSettings extends CommonClass {
|
||||
constructor(udp = []) {
|
||||
super();
|
||||
this.udp = Array.isArray(udp) ? udp.map(u => new UdpMask(u.type, u.settings)) : [new UdpMask(udp.type, udp.settings)];
|
||||
}
|
||||
|
||||
static fromJson(json = {}) {
|
||||
return new FinalMaskStreamSettings(json.udp || []);
|
||||
}
|
||||
|
||||
toJson() {
|
||||
return {
|
||||
udp: this.udp.map(udp => udp.toJson())
|
||||
};
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -627,7 +637,7 @@ class StreamSettings extends CommonClass {
|
|||
httpupgradeSettings = new HttpUpgradeStreamSettings(),
|
||||
xhttpSettings = new xHTTPStreamSettings(),
|
||||
hysteriaSettings = new HysteriaStreamSettings(),
|
||||
finalmask = { udp: [] },
|
||||
finalmask = new FinalMaskStreamSettings(),
|
||||
sockopt = undefined,
|
||||
) {
|
||||
super();
|
||||
|
|
@ -647,10 +657,7 @@ class StreamSettings extends CommonClass {
|
|||
}
|
||||
|
||||
addUdpMask(type = 'salamander') {
|
||||
if (!this.finalmask.udp) {
|
||||
this.finalmask.udp = [];
|
||||
}
|
||||
this.finalmask.udp.push(new FinalMask(type));
|
||||
this.finalmask.udp.push(new UdpMask(type));
|
||||
}
|
||||
|
||||
delUdpMask(index) {
|
||||
|
|
@ -659,6 +666,10 @@ class StreamSettings extends CommonClass {
|
|||
}
|
||||
}
|
||||
|
||||
get hasFinalMask() {
|
||||
return this.finalmask.udp && this.finalmask.udp.length > 0;
|
||||
}
|
||||
|
||||
get isTls() {
|
||||
return this.security === 'tls';
|
||||
}
|
||||
|
|
@ -676,16 +687,6 @@ class StreamSettings extends CommonClass {
|
|||
}
|
||||
|
||||
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(
|
||||
json.network,
|
||||
json.security,
|
||||
|
|
@ -698,7 +699,7 @@ class StreamSettings extends CommonClass {
|
|||
HttpUpgradeStreamSettings.fromJson(json.httpupgradeSettings),
|
||||
xHTTPStreamSettings.fromJson(json.xhttpSettings),
|
||||
HysteriaStreamSettings.fromJson(json.hysteriaSettings),
|
||||
finalmask,
|
||||
FinalMaskStreamSettings.fromJson(json.finalmask),
|
||||
SockoptStreamSettings.fromJson(json.sockopt),
|
||||
);
|
||||
}
|
||||
|
|
@ -717,9 +718,7 @@ class StreamSettings extends CommonClass {
|
|||
httpupgradeSettings: network === 'httpupgrade' ? this.httpupgrade.toJson() : undefined,
|
||||
xhttpSettings: network === 'xhttp' ? this.xhttp.toJson() : undefined,
|
||||
hysteriaSettings: network === 'hysteria' ? this.hysteria.toJson() : undefined,
|
||||
finalmask: (this.finalmask.udp && this.finalmask.udp.length > 0) ? {
|
||||
udp: this.finalmask.udp.map(mask => mask.toJson())
|
||||
} : undefined,
|
||||
finalmask: this.hasFinalMask ? this.finalmask.toJson() : undefined,
|
||||
sockopt: this.sockopt != undefined ? this.sockopt.toJson() : undefined,
|
||||
};
|
||||
}
|
||||
|
|
@ -933,8 +932,7 @@ class Outbound extends CommonClass {
|
|||
stream.tls = new TlsStreamSettings(
|
||||
json.sni,
|
||||
json.alpn ? json.alpn.split(',') : [],
|
||||
json.fp,
|
||||
json.allowInsecure);
|
||||
json.fp);
|
||||
}
|
||||
|
||||
const port = json.port * 1;
|
||||
|
|
@ -975,10 +973,9 @@ class Outbound extends CommonClass {
|
|||
if (security == 'tls') {
|
||||
let fp = url.searchParams.get('fp') ?? 'none';
|
||||
let alpn = url.searchParams.get('alpn');
|
||||
let allowInsecure = url.searchParams.get('allowInsecure');
|
||||
let sni = url.searchParams.get('sni') ?? '';
|
||||
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') {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v2/web/service"
|
||||
|
|
@ -193,6 +194,37 @@ func (a *InboundController) getClientIps(c *gin.Context) {
|
|||
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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v2/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v2/web/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
|
@ -34,9 +37,10 @@ func (a *XraySettingController) initRouter(g *gin.RouterGroup) {
|
|||
g.POST("/warp/:action", a.warp)
|
||||
g.POST("/update", a.updateSetting)
|
||||
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) {
|
||||
xraySetting, err := a.SettingService.GetXrayConfigTemplate()
|
||||
if err != nil {
|
||||
|
|
@ -48,15 +52,36 @@ func (a *XraySettingController) getXraySetting(c *gin.Context) {
|
|||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
|
||||
return
|
||||
}
|
||||
xrayResponse := "{ \"xraySetting\": " + xraySetting + ", \"inboundTags\": " + inboundTags + " }"
|
||||
jsonObj(c, xrayResponse, nil)
|
||||
outboundTestUrl, _ := a.SettingService.GetXrayOutboundTestUrl()
|
||||
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.
|
||||
func (a *XraySettingController) updateSetting(c *gin.Context) {
|
||||
xraySetting := c.PostForm("xraySetting")
|
||||
err := a.XraySettingService.SaveXraySetting(xraySetting)
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
|
||||
if err := a.XraySettingService.SaveXraySetting(xraySetting); err != nil {
|
||||
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.
|
||||
|
|
@ -118,3 +143,26 @@ func (a *XraySettingController) resetOutboundsTraffic(c *gin.Context) {
|
|||
}
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -700,14 +700,12 @@
|
|||
<a-form-item label="ECH Config List">
|
||||
<a-input v-model.trim="outbound.stream.tls.echConfigList"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Allow Insecure">
|
||||
<a-switch v-model="outbound.stream.tls.allowInsecure"></a-switch>
|
||||
</a-form-item>
|
||||
<a-form-item label="verify Peer Cert By Name">
|
||||
<a-input
|
||||
v-model.trim="outbound.stream.tls.verifyPeerCertByName"></a-input>
|
||||
v-model.trim="outbound.stream.tls.verifyPeerCertByName"
|
||||
placeholder="cloudflare-dns.com"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="pinned Peer Cert Sha256">
|
||||
<a-form-item label=" pinned Peer Cert Sha256">
|
||||
<a-input v-model.trim="outbound.stream.tls.pinnedPeerCertSha256"
|
||||
placeholder="Enter SHA256 fingerprints (base64)">
|
||||
</a-input>
|
||||
|
|
@ -772,7 +770,8 @@
|
|||
<a-switch v-model="outbound.stream.sockopt.tcpFastOpen"></a-switch>
|
||||
</a-form-item>
|
||||
<a-form-item label="Multipath TCP">
|
||||
<a-switch v-model.trim="outbound.stream.sockopt.tcpMptcp"></a-switch>
|
||||
<a-switch
|
||||
v-model.trim="outbound.stream.sockopt.tcpMptcp"></a-switch>
|
||||
</a-form-item>
|
||||
<a-form-item label="Penetrate">
|
||||
<a-switch v-model="outbound.stream.sockopt.penetrate"></a-switch>
|
||||
|
|
@ -799,7 +798,8 @@
|
|||
</a-form-item>
|
||||
<template v-if="outbound.mux.enabled">
|
||||
<a-form-item label="Concurrency">
|
||||
<a-input-number v-model.number="outbound.mux.concurrency" :min="-1"
|
||||
<a-input-number v-model.number="outbound.mux.concurrency"
|
||||
:min="-1"
|
||||
:max="1024"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="xudp Concurrency">
|
||||
|
|
|
|||
|
|
@ -45,6 +45,9 @@
|
|||
<a-select-option v-if="inbound.stream.network === 'kcp'"
|
||||
value="mkcp-original">
|
||||
mKCP Original</a-select-option>
|
||||
<a-select-option v-if="inbound.stream.network === 'kcp'"
|
||||
value="xicmp">
|
||||
xICMP (Experimental)</a-select-option>
|
||||
<!-- xDNS for TCP/WS/HTTPUpgrade/XHTTP -->
|
||||
<a-select-option
|
||||
v-if="['tcp', 'ws', 'httpupgrade', 'xhttp'].includes(inbound.stream.network)"
|
||||
|
|
@ -64,6 +67,17 @@
|
|||
<a-input v-model.trim="mask.settings.domain"
|
||||
placeholder="e.g., www.example.com"></a-input>
|
||||
</a-form-item>
|
||||
<!-- Settings for xICMP -->
|
||||
<a-form-item label='IP'
|
||||
v-if="mask.type === 'xicmp'">
|
||||
<a-input v-model.trim="mask.settings.ip"
|
||||
placeholder="e.g., 1.1.1.1"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='ID'
|
||||
v-if="mask.type === 'xicmp'">
|
||||
<a-input-number v-model.number="mask.settings.id"
|
||||
:min="0" :max="65535"></a-input-number>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</template>
|
||||
</a-form>
|
||||
|
|
|
|||
|
|
@ -58,9 +58,6 @@
|
|||
]]</a-select-option>
|
||||
</a-select>
|
||||
</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-switch v-model="inbound.stream.tls.rejectUnknownSni"></a-switch>
|
||||
</a-form-item>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -219,14 +219,14 @@
|
|||
rule = {};
|
||||
newRule = {};
|
||||
rule.type = "field";
|
||||
rule.domain = value.domain.length > 0 ? value.domain.split(',') : [];
|
||||
rule.ip = value.ip.length > 0 ? value.ip.split(',') : [];
|
||||
rule.domain = value.domain.length > 0 ? value.domain.split(',').map(s => s.trim()) : [];
|
||||
rule.ip = value.ip.length > 0 ? value.ip.split(',').map(s => s.trim()) : [];
|
||||
rule.port = value.port;
|
||||
rule.sourcePort = value.sourcePort;
|
||||
rule.vlessRoute = value.vlessRoute;
|
||||
rule.network = value.network;
|
||||
rule.sourceIP = value.sourceIP.length > 0 ? value.sourceIP.split(',') : [];
|
||||
rule.user = value.user.length > 0 ? value.user.split(',') : [];
|
||||
rule.sourceIP = value.sourceIP.length > 0 ? value.sourceIP.split(',').map(s => s.trim()) : [];
|
||||
rule.user = value.user.length > 0 ? value.user.split(',').map(s => s.trim()) : [];
|
||||
rule.inboundTag = value.inboundTag;
|
||||
rule.protocol = value.protocol;
|
||||
rule.attrs = Object.fromEntries(value.attrs);
|
||||
|
|
|
|||
|
|
@ -4,18 +4,22 @@
|
|||
<a-row :xs="24" :sm="24" :lg="12">
|
||||
<a-alert type="warning" :style="{ textAlign: 'center' }">
|
||||
<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>
|
||||
</template>
|
||||
</a-alert>
|
||||
</a-row>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.xray.FreedomStrategy" }}</template>
|
||||
<template #description>{{ i18n "pages.xray.FreedomStrategyDesc" }}</template>
|
||||
<template #description>{{ i18n "pages.xray.FreedomStrategyDesc"
|
||||
}}</template>
|
||||
<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%' }">
|
||||
<a-select-option v-for="s in OutboundDomainStrategies" :value="s">
|
||||
<a-select-option v-for="s in OutboundDomainStrategies"
|
||||
:value="s">
|
||||
<span>[[ s ]]</span>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
|
|
@ -23,42 +27,63 @@
|
|||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.xray.RoutingStrategy" }}</template>
|
||||
<template #description>{{ i18n "pages.xray.RoutingStrategyDesc" }}</template>
|
||||
<template #description>{{ i18n "pages.xray.RoutingStrategyDesc"
|
||||
}}</template>
|
||||
<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%' }">
|
||||
<a-select-option v-for="s in routingDomainStrategies" :value="s">
|
||||
<a-select-option v-for="s in routingDomainStrategies"
|
||||
:value="s">
|
||||
<span>[[ s ]]</span>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</template>
|
||||
</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 key="2" header='{{ i18n "pages.xray.statistics" }}'>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.xray.statsInboundUplink" }}</template>
|
||||
<template #description>{{ i18n "pages.xray.statsInboundUplinkDesc" }}</template>
|
||||
<template #title>{{ i18n "pages.xray.statsInboundUplink"
|
||||
}}</template>
|
||||
<template #description>{{ i18n "pages.xray.statsInboundUplinkDesc"
|
||||
}}</template>
|
||||
<template #control>
|
||||
<a-switch v-model="statsInboundUplink"></a-switch>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.xray.statsInboundDownlink" }}</template>
|
||||
<template #description>{{ i18n "pages.xray.statsInboundDownlinkDesc" }}</template>
|
||||
<template #title>{{ i18n "pages.xray.statsInboundDownlink"
|
||||
}}</template>
|
||||
<template #description>{{ i18n "pages.xray.statsInboundDownlinkDesc"
|
||||
}}</template>
|
||||
<template #control>
|
||||
<a-switch v-model="statsInboundDownlink"></a-switch>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.xray.statsOutboundUplink" }}</template>
|
||||
<template #description>{{ i18n "pages.xray.statsOutboundUplinkDesc" }}</template>
|
||||
<template #title>{{ i18n "pages.xray.statsOutboundUplink"
|
||||
}}</template>
|
||||
<template #description>{{ i18n "pages.xray.statsOutboundUplinkDesc"
|
||||
}}</template>
|
||||
<template #control>
|
||||
<a-switch v-model="statsOutboundUplink"></a-switch>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.xray.statsOutboundDownlink" }}</template>
|
||||
<template #description>{{ i18n "pages.xray.statsOutboundDownlinkDesc" }}</template>
|
||||
<template #title>{{ i18n "pages.xray.statsOutboundDownlink"
|
||||
}}</template>
|
||||
<template #description>{{ i18n
|
||||
"pages.xray.statsOutboundDownlinkDesc" }}</template>
|
||||
<template #control>
|
||||
<a-switch v-model="statsOutboundDownlink"></a-switch>
|
||||
</template>
|
||||
|
|
@ -68,16 +93,20 @@
|
|||
<a-row :xs="24" :sm="24" :lg="12">
|
||||
<a-alert type="warning" :style="{ textAlign: 'center' }">
|
||||
<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>
|
||||
</template>
|
||||
</a-alert>
|
||||
</a-row>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.xray.logLevel" }}</template>
|
||||
<template #description>{{ i18n "pages.xray.logLevelDesc" }}</template>
|
||||
<template #description>{{ i18n "pages.xray.logLevelDesc"
|
||||
}}</template>
|
||||
<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">
|
||||
<span>[[ s ]]</span>
|
||||
</a-select-option>
|
||||
|
|
@ -86,10 +115,13 @@
|
|||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.xray.accessLog" }}</template>
|
||||
<template #description>{{ i18n "pages.xray.accessLogDesc" }}</template>
|
||||
<template #description>{{ i18n "pages.xray.accessLogDesc"
|
||||
}}</template>
|
||||
<template #control>
|
||||
<a-select v-model="accessLog" :dropdown-class-name="themeSwitcher.currentTheme" :style="{ width: '100%' }">
|
||||
<a-select-option value=''>
|
||||
<a-select v-model="accessLog"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
:style="{ width: '100%' }">
|
||||
<a-select-option value>
|
||||
<span>Empty</span>
|
||||
</a-select-option>
|
||||
<a-select-option v-for="s in log.access" :value="s">
|
||||
|
|
@ -100,10 +132,13 @@
|
|||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.xray.errorLog" }}</template>
|
||||
<template #description>{{ i18n "pages.xray.errorLogDesc" }}</template>
|
||||
<template #description>{{ i18n "pages.xray.errorLogDesc"
|
||||
}}</template>
|
||||
<template #control>
|
||||
<a-select v-model="errorLog" :dropdown-class-name="themeSwitcher.currentTheme" :style="{ width: '100%' }">
|
||||
<a-select-option value=''>
|
||||
<a-select v-model="errorLog"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
:style="{ width: '100%' }">
|
||||
<a-select-option value>
|
||||
<span>Empty</span>
|
||||
</a-select-option>
|
||||
<a-select-option v-for="s in log.error" :value="s">
|
||||
|
|
@ -114,11 +149,13 @@
|
|||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.xray.maskAddress" }}</template>
|
||||
<template #description>{{ i18n "pages.xray.maskAddressDesc" }}</template>
|
||||
<template #description>{{ i18n "pages.xray.maskAddressDesc"
|
||||
}}</template>
|
||||
<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%' }">
|
||||
<a-select-option value=''>
|
||||
<a-select-option value>
|
||||
<span>Empty</span>
|
||||
</a-select-option>
|
||||
<a-select-option v-for="s in log.maskAddress" :value="s">
|
||||
|
|
@ -139,7 +176,8 @@
|
|||
<a-row :xs="24" :sm="24" :lg="12">
|
||||
<a-alert type="warning" :style="{ textAlign: 'center' }">
|
||||
<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>
|
||||
</template>
|
||||
</a-alert>
|
||||
|
|
@ -153,17 +191,21 @@
|
|||
<a-row :xs="24" :sm="24" :lg="12">
|
||||
<a-alert type="warning" :style="{ textAlign: 'center' }">
|
||||
<template slot="message">
|
||||
<a-icon type="exclamation-circle" theme="filled" :style="{ color: '#FFA031' }"></a-icon>
|
||||
<span>{{ i18n "pages.xray.blockConnectionsConfigsDesc" }}</span>
|
||||
<a-icon type="exclamation-circle" theme="filled"
|
||||
:style="{ color: '#FFA031' }"></a-icon>
|
||||
<span>{{ i18n "pages.xray.blockConnectionsConfigsDesc"
|
||||
}}</span>
|
||||
</template>
|
||||
</a-alert>
|
||||
</a-row>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.xray.blockips" }}</template>
|
||||
<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">
|
||||
<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>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
|
|
@ -172,28 +214,35 @@
|
|||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.xray.blockdomains" }}</template>
|
||||
<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">
|
||||
<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>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<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">
|
||||
<a-icon type="exclamation-circle" theme="filled" :style="{ color: '#FFA031' }"></a-icon>
|
||||
<span>{{ i18n "pages.xray.directConnectionsConfigsDesc" }}</span>
|
||||
<a-icon type="exclamation-circle" theme="filled"
|
||||
:style="{ color: '#FFA031' }"></a-icon>
|
||||
<span>{{ i18n "pages.xray.directConnectionsConfigsDesc"
|
||||
}}</span>
|
||||
</template>
|
||||
</a-alert>
|
||||
</a-row>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.xray.directips" }}</template>
|
||||
<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">
|
||||
<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>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
|
|
@ -202,18 +251,22 @@
|
|||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.xray.directdomains" }}</template>
|
||||
<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">
|
||||
<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>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<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">
|
||||
<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>
|
||||
</template>
|
||||
</a-alert>
|
||||
|
|
@ -221,18 +274,22 @@
|
|||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.xray.ipv4Routing" }}</template>
|
||||
<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">
|
||||
<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>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<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">
|
||||
<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" }}
|
||||
</template>
|
||||
</a-alert>
|
||||
|
|
@ -241,20 +298,24 @@
|
|||
<template #title>{{ i18n "pages.xray.warpRouting" }}</template>
|
||||
<template #control>
|
||||
<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">
|
||||
<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>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</template>
|
||||
<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-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-button type="danger" @click="resetXrayConfigToDefault">
|
||||
<span>{{ i18n "pages.settings.resetDefaultConfig" }}</span>
|
||||
|
|
|
|||
|
|
@ -4,17 +4,22 @@
|
|||
<a-col :xs="12" :sm="12" :lg="12">
|
||||
<a-space direction="horizontal" size="small">
|
||||
<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 type="primary" icon="cloud" @click="showWarp()">WARP</a-button>
|
||||
<a-button type="primary" icon="cloud"
|
||||
@click="showWarp()">WARP</a-button>
|
||||
</a-space>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="12" :lg="12" :style="{ textAlign: 'right' }">
|
||||
<a-button-group>
|
||||
<a-button icon="sync" @click="refreshOutboundTraffic()" :loading="refreshing"></a-button>
|
||||
<a-popconfirm placement="topRight" @confirm="resetOutboundTraffic(-1)"
|
||||
<a-button icon="sync" @click="refreshOutboundTraffic()"
|
||||
:loading="refreshing"></a-button>
|
||||
<a-popconfirm placement="topRight"
|
||||
@confirm="resetOutboundTraffic(-1)"
|
||||
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"}}'>
|
||||
<a-icon slot="icon" type="question-circle-o"
|
||||
:style="{ color: themeSwitcher.isDarkTheme ? '#008771' : '#008771' }"></a-icon>
|
||||
|
|
@ -23,8 +28,10 @@
|
|||
</a-button-group>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-table :columns="outboundColumns" bordered :row-key="r => r.key" :data-source="outboundData"
|
||||
:scroll="isMobile ? {} : { x: 800 }" :pagination="false" :indent-size="0"
|
||||
<a-table :columns="outboundColumns" bordered :row-key="r => r.key"
|
||||
:data-source="outboundData"
|
||||
:scroll="isMobile ? {} : { x: 800 }" :pagination="false"
|
||||
:indent-size="0"
|
||||
:locale='{ filterConfirm: `{{ i18n "confirm" }}`, filterReset: `{{ i18n "reset" }}` }'>
|
||||
<template slot="action" slot-scope="text, outbound, index">
|
||||
<span>[[ index+1 ]]</span>
|
||||
|
|
@ -32,7 +39,8 @@
|
|||
<a-icon @click="e => e.preventDefault()" type="more"
|
||||
:style="{ fontSize: '16px', textDecoration: 'bold' }"></a-icon>
|
||||
<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>
|
||||
<span>{{ i18n "pages.xray.rules.first"}}</span>
|
||||
</a-menu-item>
|
||||
|
|
@ -56,21 +64,64 @@
|
|||
</a-dropdown>
|
||||
</template>
|
||||
<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 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
|
||||
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' }" v-if="outbound.streamSettings.security=='tls'" color="green">tls</a-tag>
|
||||
<a-tag :style="{ margin: '0' }" v-if="outbound.streamSettings.security=='reality'"
|
||||
<a-tag :style="{ margin: '0' }" color="blue">[[
|
||||
outbound.streamSettings.network ]]</a-tag>
|
||||
<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>
|
||||
</template>
|
||||
</template>
|
||||
<template slot="traffic" slot-scope="text, outbound, index">
|
||||
<a-tag color="green">[[ findOutboundTraffic(outbound) ]]</a-tag>
|
||||
</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-space>
|
||||
{{end}}
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
{{ template "page/head_start" .}}
|
||||
<link rel="stylesheet" href="{{ .base_path }}assets/codemirror/codemirror.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/codemirror.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">
|
||||
{{ template "page/head_end" .}}
|
||||
|
||||
|
|
@ -10,10 +13,13 @@
|
|||
<a-sidebar></a-sidebar>
|
||||
<a-layout id="content-layout">
|
||||
<a-layout-content>
|
||||
<a-spin :spinning="loadingStates.spinning" :delay="500" tip='{{ i18n "loading"}}'>
|
||||
<a-spin :spinning="loadingStates.spinning" :delay="500"
|
||||
tip='{{ i18n "loading"}}'>
|
||||
<transition name="list" appear>
|
||||
<a-alert type="error" v-if="showAlert && loadingStates.fetched" :style="{ marginBottom: '10px' }"
|
||||
message='{{ i18n "secAlertTitle" }}' color="red" description='{{ i18n "secAlertSsl" }}' show-icon closable>
|
||||
<a-alert type="error" v-if="showAlert && loadingStates.fetched"
|
||||
:style="{ marginBottom: '10px' }"
|
||||
message='{{ i18n "secAlertTitle" }}' color="red"
|
||||
description='{{ i18n "secAlertSsl" }}' show-icon closable>
|
||||
</a-alert>
|
||||
</transition>
|
||||
<transition name="list" appear>
|
||||
|
|
@ -26,19 +32,25 @@
|
|||
<a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-else>
|
||||
<a-col>
|
||||
<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-space direction="horizontal">
|
||||
<a-button type="primary" :disabled="saveBtnDisable" @click="updateXraySetting">
|
||||
<a-button type="primary" :disabled="saveBtnDisable"
|
||||
@click="updateXraySetting">
|
||||
{{ i18n "pages.xray.save" }}
|
||||
</a-button>
|
||||
<a-button type="danger" :disabled="!saveBtnDisable" @click="restartXray">
|
||||
<a-button type="danger" :disabled="!saveBtnDisable"
|
||||
@click="restartXray">
|
||||
{{ i18n "pages.xray.restart" }}
|
||||
</a-button>
|
||||
<a-popover v-if="restartResult" :overlay-class-name="themeSwitcher.currentTheme">
|
||||
<span slot="title">{{ i18n "pages.index.xrayErrorPopoverTitle" }}</span>
|
||||
<a-popover v-if="restartResult"
|
||||
:overlay-class-name="themeSwitcher.currentTheme">
|
||||
<span slot="title">{{ i18n
|
||||
"pages.index.xrayErrorPopoverTitle" }}</span>
|
||||
<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>
|
||||
</template>
|
||||
<a-icon type="question-circle"></a-icon>
|
||||
|
|
@ -48,10 +60,13 @@
|
|||
<a-col :xs="24" :sm="14">
|
||||
<template>
|
||||
<div>
|
||||
<a-back-top :target="() => document.getElementById('content-layout')"
|
||||
<a-back-top
|
||||
:target="() => document.getElementById('content-layout')"
|
||||
visibility-height="200"></a-back-top>
|
||||
<a-alert type="warning" :style="{ float: 'right', width: 'fit-content' }"
|
||||
message='{{ i18n "pages.settings.infoDesc" }}' show-icon>
|
||||
<a-alert type="warning"
|
||||
:style="{ float: 'right', width: 'fit-content' }"
|
||||
message='{{ i18n "pages.settings.infoDesc" }}'
|
||||
show-icon>
|
||||
</a-alert>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -60,7 +75,8 @@
|
|||
</a-card>
|
||||
</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">
|
||||
<a-tab-pane key="tpl-basic" :style="{ paddingTop: '20px' }">
|
||||
<template #tab>
|
||||
|
|
@ -83,21 +99,24 @@
|
|||
</template>
|
||||
{{ template "settings/xray/outbounds" . }}
|
||||
</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>
|
||||
<a-icon type="import"></a-icon>
|
||||
<span>{{ i18n "pages.xray.outbound.reverse"}}</span>
|
||||
</template>
|
||||
{{ template "settings/xray/reverse" . }}
|
||||
</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>
|
||||
<a-icon type="cluster"></a-icon>
|
||||
<span>{{ i18n "pages.xray.Balancers"}}</span>
|
||||
</template>
|
||||
{{ template "settings/xray/balancers" . }}
|
||||
</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>
|
||||
<a-icon type="database"></a-icon>
|
||||
<span>DNS</span>
|
||||
|
|
@ -120,14 +139,18 @@
|
|||
</a-layout>
|
||||
</a-layout>
|
||||
{{template "page/body_scripts" .}}
|
||||
<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/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/jshint.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/javascript-lint.js"></script>
|
||||
<script src="{{ .base_path }}assets/codemirror/hint/javascript-hint.js"></script>
|
||||
<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/foldgutter.js"></script>
|
||||
<script src="{{ .base_path }}assets/codemirror/fold/brace-fold.js"></script>
|
||||
|
|
@ -181,11 +204,13 @@
|
|||
];
|
||||
|
||||
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 "protocol"}}', align: 'center', width: 50, scopedSlots: { customRender: 'protocol' } },
|
||||
{ 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.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 = [
|
||||
|
|
@ -228,8 +253,11 @@
|
|||
},
|
||||
oldXraySetting: '',
|
||||
xraySetting: '',
|
||||
outboundTestUrl: 'https://www.google.com/generate_204',
|
||||
oldOutboundTestUrl: 'https://www.google.com/generate_204',
|
||||
inboundTags: [],
|
||||
outboundsTraffic: [],
|
||||
outboundTestStates: {}, // Track testing state and results for each outbound
|
||||
saveBtnDisable: true,
|
||||
refreshing: false,
|
||||
restartResult: '',
|
||||
|
|
@ -337,14 +365,14 @@
|
|||
},
|
||||
defaultObservatory: {
|
||||
subjectSelector: [],
|
||||
probeURL: "http://www.google.com/gen_204",
|
||||
probeURL: "https://www.google.com/generate_204",
|
||||
probeInterval: "10m",
|
||||
enableConcurrency: true
|
||||
},
|
||||
defaultBurstObservatory: {
|
||||
subjectSelector: [],
|
||||
pingConfig: {
|
||||
destination: "http://www.google.com/gen_204",
|
||||
destination: "https://www.google.com/generate_204",
|
||||
interval: "30m",
|
||||
connectivity: "http://connectivitycheck.platform.hicloud.com/generate_204",
|
||||
timeout: "10s",
|
||||
|
|
@ -375,12 +403,17 @@
|
|||
this.oldXraySetting = xs;
|
||||
this.xraySetting = xs;
|
||||
this.inboundTags = result.inboundTags;
|
||||
this.outboundTestUrl = result.outboundTestUrl || 'https://www.google.com/generate_204';
|
||||
this.oldOutboundTestUrl = this.outboundTestUrl;
|
||||
this.saveBtnDisable = true;
|
||||
}
|
||||
},
|
||||
async updateXraySetting() {
|
||||
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);
|
||||
if (msg.success) {
|
||||
await this.getXraySetting();
|
||||
|
|
@ -595,6 +628,71 @@
|
|||
outbounds.splice(0, 0, outbounds.splice(index, 1)[0]);
|
||||
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() {
|
||||
reverseModal.show({
|
||||
title: '{{ i18n "pages.xray.outbound.addReverse"}}',
|
||||
|
|
@ -981,7 +1079,7 @@
|
|||
|
||||
while (true) {
|
||||
await PromiseUtil.sleep(800);
|
||||
this.saveBtnDisable = this.oldXraySetting === this.xraySetting;
|
||||
this.saveBtnDisable = this.oldXraySetting === this.xraySetting && this.oldOutboundTestUrl === this.outboundTestUrl;
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"regexp"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v2/database"
|
||||
|
|
@ -18,6 +19,12 @@ import (
|
|||
"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.
|
||||
type CheckClientIpJob struct {
|
||||
lastClear int64
|
||||
|
|
@ -119,12 +126,14 @@ func (j *CheckClientIpJob) processLogFile() bool {
|
|||
|
||||
ipRegex := regexp.MustCompile(`from (?:tcp:|udp:)?\[?([0-9a-fA-F\.:]+)\]?:\d+ accepted`)
|
||||
emailRegex := regexp.MustCompile(`email: (.+)$`)
|
||||
timestampRegex := regexp.MustCompile(`^(\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2})`)
|
||||
|
||||
accessLogPath, _ := xray.GetAccessLogPath()
|
||||
file, _ := os.Open(accessLogPath)
|
||||
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)
|
||||
for scanner.Scan() {
|
||||
|
|
@ -147,28 +156,45 @@ func (j *CheckClientIpJob) processLogFile() bool {
|
|||
}
|
||||
email := emailMatches[1]
|
||||
|
||||
if _, exists := inboundClientIps[email]; !exists {
|
||||
inboundClientIps[email] = make(map[string]struct{})
|
||||
// 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 {
|
||||
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
|
||||
for email, uniqueIps := range inboundClientIps {
|
||||
for email, ipTimestamps := range inboundClientIps {
|
||||
|
||||
ips := make([]string, 0, len(uniqueIps))
|
||||
for ip := range uniqueIps {
|
||||
ips = append(ips, ip)
|
||||
// Convert to IPWithTimestamp slice
|
||||
ipsWithTime := make([]IPWithTimestamp, 0, len(ipTimestamps))
|
||||
for ip, timestamp := range ipTimestamps {
|
||||
ipsWithTime = append(ipsWithTime, IPWithTimestamp{IP: ip, Timestamp: timestamp})
|
||||
}
|
||||
sort.Strings(ips)
|
||||
|
||||
clientIpsRecord, err := j.getInboundClientIps(email)
|
||||
if err != nil {
|
||||
j.addInboundClientIps(email, ips)
|
||||
j.addInboundClientIps(email, ipsWithTime)
|
||||
continue
|
||||
}
|
||||
|
||||
shouldCleanLog = j.updateInboundClientIps(clientIpsRecord, email, ips) || shouldCleanLog
|
||||
shouldCleanLog = j.updateInboundClientIps(clientIpsRecord, email, ipsWithTime) || shouldCleanLog
|
||||
}
|
||||
|
||||
return shouldCleanLog
|
||||
|
|
@ -213,9 +239,9 @@ func (j *CheckClientIpJob) getInboundClientIps(clientEmail string) (*model.Inbou
|
|||
return InboundClientIps, nil
|
||||
}
|
||||
|
||||
func (j *CheckClientIpJob) addInboundClientIps(clientEmail string, ips []string) error {
|
||||
func (j *CheckClientIpJob) addInboundClientIps(clientEmail string, ipsWithTime []IPWithTimestamp) error {
|
||||
inboundClientIps := &model.InboundClientIps{}
|
||||
jsonIps, err := json.Marshal(ips)
|
||||
jsonIps, err := json.Marshal(ipsWithTime)
|
||||
j.checkError(err)
|
||||
|
||||
inboundClientIps.ClientEmail = clientEmail
|
||||
|
|
@ -239,16 +265,8 @@ func (j *CheckClientIpJob) addInboundClientIps(clientEmail string, ips []string)
|
|||
return nil
|
||||
}
|
||||
|
||||
func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.InboundClientIps, clientEmail string, ips []string) bool {
|
||||
jsonIps, err := json.Marshal(ips)
|
||||
if err != nil {
|
||||
logger.Error("failed to marshal IPs to JSON:", err)
|
||||
return false
|
||||
}
|
||||
|
||||
inboundClientIps.ClientEmail = clientEmail
|
||||
inboundClientIps.Ips = string(jsonIps)
|
||||
|
||||
func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.InboundClientIps, clientEmail string, newIpsWithTime []IPWithTimestamp) bool {
|
||||
// Get the inbound configuration
|
||||
inbound, err := j.getInboundByEmail(clientEmail)
|
||||
if err != nil {
|
||||
logger.Errorf("failed to fetch inbound settings for email %s: %s", clientEmail, err)
|
||||
|
|
@ -263,9 +281,57 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
|
|||
settings := map[string][]model.Client{}
|
||||
json.Unmarshal([]byte(inbound.Settings), &settings)
|
||||
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, keeping the latest timestamp for each IP
|
||||
ipMap := make(map[string]int64)
|
||||
for _, ipTime := range oldIpsWithTime {
|
||||
ipMap[ipTime.IP] = ipTime.Timestamp
|
||||
}
|
||||
for _, ipTime := range newIpsWithTime {
|
||||
if existingTime, ok := ipMap[ipTime.IP]; !ok || ipTime.Timestamp > existingTime {
|
||||
ipMap[ipTime.IP] = ipTime.Timestamp
|
||||
}
|
||||
}
|
||||
|
||||
// Convert back to slice and sort by timestamp (newest first)
|
||||
allIps := make([]IPWithTimestamp, 0, len(ipMap))
|
||||
for ip, timestamp := range ipMap {
|
||||
allIps = append(allIps, IPWithTimestamp{IP: ip, Timestamp: timestamp})
|
||||
}
|
||||
sort.Slice(allIps, func(i, j int) bool {
|
||||
return allIps[i].Timestamp > allIps[j].Timestamp // Descending order (newest first)
|
||||
})
|
||||
|
||||
shouldCleanLog := false
|
||||
j.disAllowedIps = []string{}
|
||||
|
||||
// Open log file
|
||||
logIpFile, err := os.OpenFile(xray.GetIPLimitLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
logger.Errorf("failed to open IP limit log file: %s", err)
|
||||
|
|
@ -275,27 +341,33 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
|
|||
log.SetOutput(logIpFile)
|
||||
log.SetFlags(log.LstdFlags)
|
||||
|
||||
for _, client := range clients {
|
||||
if client.Email == clientEmail {
|
||||
limitIp := client.LimitIP
|
||||
// Check if we exceed the limit
|
||||
if len(allIps) > limitIp {
|
||||
shouldCleanLog = true
|
||||
|
||||
if limitIp > 0 && inbound.Enable {
|
||||
shouldCleanLog = true
|
||||
// Keep only the newest IPs (up to limitIp)
|
||||
keptIps := allIps[:limitIp]
|
||||
disconnectedIps := allIps[limitIp:]
|
||||
|
||||
if limitIp < len(ips) {
|
||||
j.disAllowedIps = append(j.disAllowedIps, ips[limitIp:]...)
|
||||
for i := limitIp; i < len(ips); i++ {
|
||||
log.Printf("[LIMIT_IP] Email = %s || SRC = %s", clientEmail, ips[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
// Log the disconnected IPs (old ones)
|
||||
for _, ipTime := range disconnectedIps {
|
||||
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)
|
||||
// Actually disconnect old IPs by temporarily removing and re-adding user
|
||||
// This forces Xray to drop existing connections from old IPs
|
||||
if len(disconnectedIps) > 0 {
|
||||
j.disconnectClientTemporarily(inbound, clientEmail, clients)
|
||||
}
|
||||
|
||||
if len(j.disAllowedIps) > 0 {
|
||||
logger.Debug("disAllowedIps:", j.disAllowedIps)
|
||||
// Update database with only the newest IPs
|
||||
jsonIps, _ := json.Marshal(keptIps)
|
||||
inboundClientIps.Ips = string(jsonIps)
|
||||
} else {
|
||||
// Under limit, save all IPs
|
||||
jsonIps, _ := json.Marshal(allIps)
|
||||
inboundClientIps.Ips = string(jsonIps)
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
|
|
@ -305,9 +377,68 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
|
|||
return false
|
||||
}
|
||||
|
||||
if len(j.disAllowedIps) > 0 {
|
||||
logger.Infof("[LIMIT_IP] Client %s: Kept %d newest IPs, disconnected %d old IPs", clientEmail, limitIp, len(j.disAllowedIps))
|
||||
}
|
||||
|
||||
return shouldCleanLog
|
||||
}
|
||||
|
||||
// disconnectClientTemporarily removes and re-adds a client to force disconnect old connections
|
||||
func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, clientEmail string, clients []model.Client) {
|
||||
var xrayAPI xray.XrayAPI
|
||||
|
||||
// Get panel settings for API port
|
||||
db := database.GetDB()
|
||||
var apiPort int
|
||||
var apiPortSetting model.Setting
|
||||
if err := db.Where("key = ?", "xrayApiPort").First(&apiPortSetting).Error; err == nil {
|
||||
apiPort, _ = strconv.Atoi(apiPortSetting.Value)
|
||||
}
|
||||
|
||||
if apiPort == 0 {
|
||||
apiPort = 10085 // Default API port
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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(string(inbound.Protocol), inbound.Tag, clientConfig)
|
||||
if err != nil {
|
||||
logger.Warningf("[LIMIT_IP] Failed to re-add user %s: %v", clientEmail, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (j *CheckClientIpJob) getInboundByEmail(clientEmail string) (*model.Inbound, error) {
|
||||
db := database.GetDB()
|
||||
inbound := &model.Inbound{}
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ func (j *XrayTrafficJob) Run() {
|
|||
}
|
||||
|
||||
// Broadcast traffic update via WebSocket with accumulated values from database
|
||||
trafficUpdate := map[string]interface{}{
|
||||
trafficUpdate := map[string]any{
|
||||
"traffics": traffics,
|
||||
"clientTraffics": clientTraffics,
|
||||
"onlineClients": onlineClients,
|
||||
|
|
|
|||
|
|
@ -2141,6 +2141,43 @@ func (s *InboundService) GetInboundClientIps(clientEmail string) (string, error)
|
|||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,22 @@
|
|||
package service
|
||||
|
||||
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/model"
|
||||
"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"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
|
@ -13,6 +26,9 @@ import (
|
|||
// It handles outbound traffic monitoring and statistics.
|
||||
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) {
|
||||
var err error
|
||||
db := database.GetDB()
|
||||
|
|
@ -100,3 +116,307 @@ func (s *OutboundService) ResetOutboundTraffic(tag string) error {
|
|||
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -567,7 +567,7 @@ func (s *ServerService) GetXrayVersions() ([]string, error) {
|
|||
continue
|
||||
}
|
||||
|
||||
if major > 26 || (major == 26 && minor > 1) || (major == 26 && minor == 1 && patch >= 31) {
|
||||
if major > 26 || (major == 26 && minor > 2) || (major == 26 && minor == 2 && patch >= 6) {
|
||||
versions = append(versions, release.TagName)
|
||||
}
|
||||
}
|
||||
|
|
@ -1056,44 +1056,79 @@ func (s *ServerService) IsValidGeofileName(filename string) bool {
|
|||
}
|
||||
|
||||
func (s *ServerService) UpdateGeofile(fileName string) error {
|
||||
files := []struct {
|
||||
type geofileEntry struct {
|
||||
URL string
|
||||
FileName string
|
||||
}{
|
||||
{"https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat", "geoip.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/geoip.dat", "geoip_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/geoip.dat", "geoip_RU.dat"},
|
||||
{"https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat", "geosite_RU.dat"},
|
||||
}
|
||||
geofileAllowlist := map[string]geofileEntry{
|
||||
"geoip.dat": {"https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat", "geoip.dat"},
|
||||
"geosite.dat": {"https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat", "geosite.dat"},
|
||||
"geoip_IR.dat": {"https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat", "geoip_IR.dat"},
|
||||
"geosite_IR.dat": {"https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat", "geosite_IR.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
|
||||
if fileName != "" {
|
||||
// Use the centralized validation function
|
||||
if !s.IsValidGeofileName(fileName) {
|
||||
return common.NewErrorf("Invalid geofile name: contains unsafe path characters: %s", fileName)
|
||||
}
|
||||
|
||||
// Ensure the filename matches exactly one from our allowlist
|
||||
isAllowed := false
|
||||
for _, file := range files {
|
||||
if fileName == file.FileName {
|
||||
isAllowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isAllowed {
|
||||
return common.NewErrorf("Invalid geofile name: %s not in allowlist", fileName)
|
||||
if _, ok := geofileAllowlist[fileName]; !ok {
|
||||
return common.NewErrorf("Invalid geofile name: %q not in allowlist", fileName)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
return common.NewErrorf("Failed to download Geofile from %s: %v", url, err)
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
updateFileModTime()
|
||||
return nil
|
||||
}
|
||||
|
||||
var errorMessages []string
|
||||
|
||||
if fileName == "" {
|
||||
for _, file := range files {
|
||||
// Sanitize the filename from our allowlist as an extra precaution
|
||||
destPath := filepath.Join(config.GetBinFolderPath(), filepath.Base(file.FileName))
|
||||
|
||||
if err := downloadFile(file.URL, destPath); err != nil {
|
||||
errorMessages = append(errorMessages, fmt.Sprintf("Error downloading Geofile '%s': %v", file.FileName, err))
|
||||
// Download all geofiles
|
||||
for _, entry := range geofileAllowlist {
|
||||
destPath := filepath.Join(config.GetBinFolderPath(), entry.FileName)
|
||||
if err := downloadFile(entry.URL, destPath); err != nil {
|
||||
errorMessages = append(errorMessages, fmt.Sprintf("Error downloading Geofile '%s': %v", entry.FileName, err))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Use filepath.Base to ensure we only get the filename component, no path traversal
|
||||
safeName := filepath.Base(fileName)
|
||||
destPath := filepath.Join(config.GetBinFolderPath(), safeName)
|
||||
|
||||
var fileURL string
|
||||
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))
|
||||
}
|
||||
entry := geofileAllowlist[fileName]
|
||||
destPath := filepath.Join(config.GetBinFolderPath(), entry.FileName)
|
||||
if err := downloadFile(entry.URL, destPath); err != nil {
|
||||
errorMessages = append(errorMessages, fmt.Sprintf("Error downloading Geofile '%s': %v", entry.FileName, err))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
|
@ -78,6 +79,8 @@ var defaultValueMap = map[string]string{
|
|||
"warp": "",
|
||||
"externalTrafficInformEnable": "false",
|
||||
"externalTrafficInformURI": "",
|
||||
"xrayOutboundTestUrl": "https://www.google.com/generate_204",
|
||||
|
||||
// LDAP defaults
|
||||
"ldapEnable": "false",
|
||||
"ldapHost": "",
|
||||
|
|
@ -271,6 +274,14 @@ func (s *SettingService) GetXrayConfigTemplate() (string, error) {
|
|||
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) {
|
||||
return s.getString("webListen")
|
||||
}
|
||||
|
|
@ -707,6 +718,28 @@ func (s *SettingService) GetDefaultXrayConfig() (any, error) {
|
|||
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) {
|
||||
type settingFunc func() (any, error)
|
||||
settings := map[string]settingFunc{
|
||||
|
|
@ -757,7 +790,7 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
|
|||
subTLS = true
|
||||
}
|
||||
if subDomain == "" {
|
||||
subDomain = strings.Split(host, ":")[0]
|
||||
subDomain = extractHostname(host)
|
||||
}
|
||||
if subTLS {
|
||||
subURI = "https://"
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"crypto/rand"
|
||||
"embed"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
|
@ -2322,9 +2323,9 @@ func (t *Tgbot) buildSubscriptionURLs(email string) (string, string, error) {
|
|||
// If pre-configured URIs are available, use them directly
|
||||
if subURI != "" {
|
||||
if !strings.HasSuffix(subURI, "/") {
|
||||
subURI = subURI + "/"
|
||||
subURI = subURI + "/"
|
||||
}
|
||||
subURL = fmt.Sprintf("%s%s", subURI, client.SubID)
|
||||
subURL = fmt.Sprintf("%s%s", subURI, client.SubID)
|
||||
} else {
|
||||
subURL = fmt.Sprintf("%s://%s%s%s", scheme, host, subPath, client.SubID)
|
||||
}
|
||||
|
|
@ -2333,7 +2334,7 @@ func (t *Tgbot) buildSubscriptionURLs(email string) (string, string, error) {
|
|||
if !strings.HasSuffix(subJsonURI, "/") {
|
||||
subJsonURI = subJsonURI + "/"
|
||||
}
|
||||
subJsonURL = fmt.Sprintf("%s%s", subJsonURI, client.SubID)
|
||||
subJsonURL = fmt.Sprintf("%s%s", subJsonURI, client.SubID)
|
||||
} else {
|
||||
|
||||
subJsonURL = fmt.Sprintf("%s://%s%s%s", scheme, host, subJsonPath, client.SubID)
|
||||
|
|
@ -3083,9 +3084,41 @@ func (t *Tgbot) searchClientIps(chatId int64, email string, messageID ...int) {
|
|||
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 += 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"))
|
||||
|
||||
inlineKeyboard := tu.InlineKeyboard(
|
||||
|
|
|
|||
|
|
@ -460,6 +460,8 @@
|
|||
"FreedomStrategyDesc" = "اختار استراتيجية المخرجات للشبكة في بروتوكول الحرية."
|
||||
"RoutingStrategy" = "استراتيجية التوجيه العامة"
|
||||
"RoutingStrategyDesc" = "حدد استراتيجية التوجيه الإجمالية لحل كل الطلبات."
|
||||
"outboundTestUrl" = "رابط اختبار المخرج"
|
||||
"outboundTestUrlDesc" = "الرابط المستخدم عند اختبار اتصال المخرج"
|
||||
"Torrent" = "حظر بروتوكول التورنت"
|
||||
"Inbounds" = "الإدخالات"
|
||||
"InboundsDesc" = "قبول العملاء المعينين."
|
||||
|
|
|
|||
|
|
@ -460,6 +460,8 @@
|
|||
"FreedomStrategyDesc" = "Set the output strategy for the network in the Freedom Protocol."
|
||||
"RoutingStrategy" = "Overall Routing Strategy"
|
||||
"RoutingStrategyDesc" = "Set the overall traffic routing strategy for resolving all requests."
|
||||
"outboundTestUrl" = "Outbound Test URL"
|
||||
"outboundTestUrlDesc" = "URL used when testing outbound connectivity."
|
||||
"Torrent" = "Block BitTorrent Protocol"
|
||||
"Inbounds" = "Inbounds"
|
||||
"InboundsDesc" = "Accepting the specific clients."
|
||||
|
|
@ -523,6 +525,12 @@
|
|||
"accountInfo" = "Account Information"
|
||||
"outboundStatus" = "Outbound Status"
|
||||
"sendThrough" = "Send Through"
|
||||
"test" = "Test"
|
||||
"testResult" = "Test Result"
|
||||
"testing" = "Testing connection..."
|
||||
"testSuccess" = "Test successful"
|
||||
"testFailed" = "Test failed"
|
||||
"testError" = "Failed to test outbound"
|
||||
|
||||
[pages.xray.balancer]
|
||||
"addBalancer" = "Add Balancer"
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
"copy" = "Copiar"
|
||||
"copied" = "Copiado"
|
||||
"download" = "Descargar"
|
||||
"remark" = "Nota"
|
||||
"remark" = "Notas"
|
||||
"enable" = "Habilitar"
|
||||
"protocol" = "Protocolo"
|
||||
"search" = "Buscar"
|
||||
|
|
@ -28,14 +28,14 @@
|
|||
"edit" = "Editar"
|
||||
"delete" = "Eliminar"
|
||||
"reset" = "Restablecer"
|
||||
"noData" = "Sin datos."
|
||||
"noData" = "Sin datos"
|
||||
"copySuccess" = "Copiado exitosamente"
|
||||
"sure" = "Seguro"
|
||||
"encryption" = "Encriptación"
|
||||
"useIPv4ForHost" = "Usar IPv4 para el host"
|
||||
"transmission" = "Transmisión"
|
||||
"host" = "Anfitrión"
|
||||
"path" = "Ruta"
|
||||
"host" = "Host"
|
||||
"path" = "Path"
|
||||
"camouflage" = "Camuflaje"
|
||||
"status" = "Estado"
|
||||
"enabled" = "Habilitado"
|
||||
|
|
@ -114,7 +114,7 @@
|
|||
"cpu" = "CPU"
|
||||
"logicalProcessors" = "Procesadores lógicos"
|
||||
"frequency" = "Frecuencia"
|
||||
"swap" = "Intercambio"
|
||||
"swap" = "Memoria Virtual"
|
||||
"storage" = "Almacenamiento"
|
||||
"memory" = "RAM"
|
||||
"threads" = "Hilos"
|
||||
|
|
@ -167,7 +167,7 @@
|
|||
|
||||
[pages.inbounds]
|
||||
"allTimeTraffic" = "Tráfico Total"
|
||||
"allTimeTrafficUsage" = "Uso total de todos los tiempos"
|
||||
"allTimeTrafficUsage" = "Uso de datos histórico"
|
||||
"title" = "Entradas"
|
||||
"totalDownUp" = "Subidas/Descargas Totales"
|
||||
"totalUsage" = "Uso Total"
|
||||
|
|
@ -201,7 +201,7 @@
|
|||
"destinationPort" = "Puerto de Destino"
|
||||
"targetAddress" = "Dirección de Destino"
|
||||
"monitorDesc" = "Dejar en blanco por defecto"
|
||||
"meansNoLimit" = "= illimitata. (unidad: GB)"
|
||||
"meansNoLimit" = " = illimitata. (unidad: GB)"
|
||||
"totalFlow" = "Flujo Total"
|
||||
"leaveBlankToNeverExpire" = "Dejar en Blanco para Nunca Expirar"
|
||||
"noRecommendKeepDefault" = "No hay requisitos especiales para mantener la configuración predeterminada"
|
||||
|
|
@ -283,7 +283,7 @@
|
|||
"inboundClientAddSuccess" = "Cliente(s) de entrada añadido(s)"
|
||||
"inboundClientDeleteSuccess" = "Cliente de entrada eliminado"
|
||||
"inboundClientUpdateSuccess" = "Cliente de entrada actualizado"
|
||||
"delDepletedClientsSuccess" = "Todos los clientes agotados fueron eliminados"
|
||||
"delDepletedClientsSuccess" = "Todos los clientes con tráfico agotado fueron eliminados"
|
||||
"resetAllClientTrafficSuccess" = "Todo el tráfico del cliente ha sido reiniciado"
|
||||
"resetAllTrafficSuccess" = "Todo el tráfico ha sido reiniciado"
|
||||
"resetInboundClientTrafficSuccess" = "El tráfico ha sido reiniciado"
|
||||
|
|
@ -373,7 +373,7 @@
|
|||
"subEnableDesc" = "Función de suscripción con configuración separada."
|
||||
"subJsonEnable" = "Habilitar/Deshabilitar el endpoint de suscripción JSON de forma independiente."
|
||||
"subTitle" = "Título de la Suscripción"
|
||||
"subTitleDesc" = "Título mostrado en el cliente de VPN"
|
||||
"subTitleDesc" = "Título mostrado en el cliente VPN"
|
||||
"subSupportUrl" = "URL de soporte"
|
||||
"subSupportUrlDesc" = "Enlace de soporte técnico mostrado en el cliente VPN"
|
||||
"subProfileUrl" = "URL del perfil"
|
||||
|
|
@ -411,8 +411,8 @@
|
|||
"fragment" = "Fragmentación"
|
||||
"fragmentDesc" = "Habilitar la fragmentación para el paquete de saludo de TLS"
|
||||
"fragmentSett" = "Configuración de Fragmentación"
|
||||
"noisesDesc" = "Activar Noises."
|
||||
"noisesSett" = "Configuración de Noises"
|
||||
"noisesDesc" = "Activar Sonidos"
|
||||
"noisesSett" = "Configuración de Sonidos"
|
||||
"mux" = "Mux"
|
||||
"muxDesc" = "Transmite múltiples flujos de datos independientes dentro de un flujo de datos establecido."
|
||||
"muxSett" = "Configuración Mux"
|
||||
|
|
@ -436,8 +436,8 @@
|
|||
"stopSuccess" = "Xray se ha detenido correctamente"
|
||||
"restartError" = "Ocurrió un error al reiniciar Xray."
|
||||
"stopError" = "Ocurrió un error al detener Xray."
|
||||
"basicTemplate" = "Plantilla Básica"
|
||||
"advancedTemplate" = "Plantilla Avanzada"
|
||||
"basicTemplate" = "Perfil Básico"
|
||||
"advancedTemplate" = "Perfil Avanzado"
|
||||
"generalConfigs" = "Configuraciones Generales"
|
||||
"generalConfigsDesc" = "Estas opciones proporcionarán ajustes generales."
|
||||
"logConfigs" = "Registro"
|
||||
|
|
@ -460,6 +460,8 @@
|
|||
"FreedomStrategyDesc" = "Establece la estrategia de salida de la red en el Protocolo Freedom."
|
||||
"RoutingStrategy" = "Configurar Estrategia de Enrutamiento de Dominios"
|
||||
"RoutingStrategyDesc" = "Establece la estrategia general de enrutamiento para la resolución de DNS."
|
||||
"outboundTestUrl" = "URL de prueba de outbound"
|
||||
"outboundTestUrlDesc" = "URL usada al probar la conectividad del outbound"
|
||||
"Torrent" = "Prohibir Uso de BitTorrent"
|
||||
"Inbounds" = "Entrante"
|
||||
"InboundsDesc" = "Cambia la plantilla de configuración para aceptar clientes específicos."
|
||||
|
|
@ -610,8 +612,8 @@
|
|||
|
||||
[tgbot]
|
||||
"keyboardClosed" = "❌ Teclado cerrado!"
|
||||
"noResult" = "❗ ¡No hay resultados!"
|
||||
"noQuery" = "❌ ¡Consulta no encontrada! ¡Por favor, use el comando de nuevo!"
|
||||
"noResult" = "❗ ¡Sin resultados!"
|
||||
"noQuery" = "❌ ¡Consulta no encontrada! ¡Por favor, use el comando nuevamente!"
|
||||
"wentWrong" = "❌ ¡Algo salió mal!"
|
||||
"noIpRecord" = "❗ ¡No hay registro de IP!"
|
||||
"noInbounds" = "❗ ¡No se encontraron entradas!"
|
||||
|
|
|
|||
|
|
@ -460,6 +460,8 @@
|
|||
"FreedomStrategyDesc" = "تعیین میکند Freedom استراتژی خروجی شبکه را برای پروتکل"
|
||||
"RoutingStrategy" = "استراتژی کلی مسیریابی"
|
||||
"RoutingStrategyDesc" = "استراتژی کلی مسیریابی برای حل تمام درخواستها را تعیین میکند"
|
||||
"outboundTestUrl" = "آدرس تست خروجی"
|
||||
"outboundTestUrlDesc" = "آدرسی که برای تست اتصال خروجی استفاده میشود."
|
||||
"Torrent" = "مسدودسازی پروتکل بیتتورنت"
|
||||
"Inbounds" = "ورودیها"
|
||||
"InboundsDesc" = "پذیرش کلاینت خاص"
|
||||
|
|
|
|||
|
|
@ -460,6 +460,8 @@
|
|||
"FreedomStrategyDesc" = "Atur strategi output untuk jaringan dalam Protokol Freedom."
|
||||
"RoutingStrategy" = "Strategi Pengalihan Keseluruhan"
|
||||
"RoutingStrategyDesc" = "Atur strategi pengalihan lalu lintas keseluruhan untuk menyelesaikan semua permintaan."
|
||||
"outboundTestUrl" = "URL tes outbound"
|
||||
"outboundTestUrlDesc" = "URL yang digunakan saat menguji konektivitas outbound"
|
||||
"Torrent" = "Blokir Protokol BitTorrent"
|
||||
"Inbounds" = "Masuk"
|
||||
"InboundsDesc" = "Menerima klien tertentu."
|
||||
|
|
|
|||
|
|
@ -460,6 +460,8 @@
|
|||
"FreedomStrategyDesc" = "Freedomプロトコル内のネットワークの出力戦略を設定する"
|
||||
"RoutingStrategy" = "ルーティングドメイン戦略設定"
|
||||
"RoutingStrategyDesc" = "DNS解決の全体的なルーティング戦略を設定する"
|
||||
"outboundTestUrl" = "アウトバウンドテスト URL"
|
||||
"outboundTestUrlDesc" = "アウトバウンド接続テストに使用する URL。既定値"
|
||||
"Torrent" = "BitTorrent プロトコルをブロック"
|
||||
"Inbounds" = "インバウンドルール"
|
||||
"InboundsDesc" = "特定のクライアントからのトラフィックを受け入れる"
|
||||
|
|
|
|||
|
|
@ -460,6 +460,8 @@
|
|||
"FreedomStrategyDesc" = "Definir a estratégia de saída para a rede no Protocolo Freedom."
|
||||
"RoutingStrategy" = "Estratégia Geral de Roteamento"
|
||||
"RoutingStrategyDesc" = "Definir a estratégia geral de roteamento de tráfego para resolver todas as solicitações."
|
||||
"outboundTestUrl" = "URL de teste de outbound"
|
||||
"outboundTestUrlDesc" = "URL usada ao testar conectividade do outbound"
|
||||
"Torrent" = "Bloquear Protocolo BitTorrent"
|
||||
"Inbounds" = "Inbounds"
|
||||
"InboundsDesc" = "Aceitar clientes específicos."
|
||||
|
|
|
|||
|
|
@ -460,6 +460,8 @@
|
|||
"FreedomStrategyDesc" = "Установка стратегии вывода сети в протоколе Freedom"
|
||||
"RoutingStrategy" = "Настройка маршрутизации доменов"
|
||||
"RoutingStrategyDesc" = "Установка общей стратегии маршрутизации разрешения DNS"
|
||||
"outboundTestUrl" = "URL для теста исходящего"
|
||||
"outboundTestUrlDesc" = "URL для проверки подключения исходящего"
|
||||
"Torrent" = "Заблокировать BitTorrent"
|
||||
"Inbounds" = "Входящие подключения"
|
||||
"InboundsDesc" = "Изменение шаблона конфигурации для подключения определенных клиентов"
|
||||
|
|
|
|||
|
|
@ -460,6 +460,8 @@
|
|||
"FreedomStrategyDesc" = "Freedom Protokolünde ağın çıkış stratejisini ayarlayın."
|
||||
"RoutingStrategy" = "Genel Yönlendirme Stratejisi"
|
||||
"RoutingStrategyDesc" = "Tüm istekleri çözmek için genel trafik yönlendirme stratejisini ayarlayın."
|
||||
"outboundTestUrl" = "Outbound test URL"
|
||||
"outboundTestUrlDesc" = "Outbound bağlantı testinde kullanılan URL"
|
||||
"Torrent" = "BitTorrent Protokolünü Engelle"
|
||||
"Inbounds" = "Gelenler"
|
||||
"InboundsDesc" = "Belirli müşterileri kabul eder."
|
||||
|
|
|
|||
|
|
@ -460,6 +460,8 @@
|
|||
"FreedomStrategyDesc" = "Установити стратегію виведення для мережі в протоколі свободи."
|
||||
"RoutingStrategy" = "Загальна стратегія маршрутизації"
|
||||
"RoutingStrategyDesc" = "Установити загальну стратегію маршрутизації трафіку для вирішення всіх запитів."
|
||||
"outboundTestUrl" = "URL тесту outbound"
|
||||
"outboundTestUrlDesc" = "URL для перевірки з'єднання outbound"
|
||||
"Torrent" = "Блокувати протокол BitTorrent"
|
||||
"Inbounds" = "Вхідні"
|
||||
"InboundsDesc" = "Прийняття певних клієнтів."
|
||||
|
|
|
|||
|
|
@ -460,6 +460,8 @@
|
|||
"FreedomStrategyDesc" = "Đặt chiến lược đầu ra của mạng trong Giao thức Freedom."
|
||||
"RoutingStrategy" = "Cấu hình Chiến lược Định tuyến Tên miền"
|
||||
"RoutingStrategyDesc" = "Đặt chiến lược định tuyến tổng thể cho việc giải quyết DNS."
|
||||
"outboundTestUrl" = "URL kiểm tra outbound"
|
||||
"outboundTestUrlDesc" = "URL dùng khi kiểm tra kết nối outbound"
|
||||
"Torrent" = "Cấu hình sử dụng BitTorrent"
|
||||
"Inbounds" = "Đầu vào"
|
||||
"InboundsDesc" = "Thay đổi mẫu cấu hình để chấp nhận các máy khách cụ thể."
|
||||
|
|
|
|||
|
|
@ -460,6 +460,8 @@
|
|||
"FreedomStrategyDesc" = "设置 Freedom 协议中网络的输出策略"
|
||||
"RoutingStrategy" = "配置路由域策略"
|
||||
"RoutingStrategyDesc" = "设置 DNS 解析的整体路由策略"
|
||||
"outboundTestUrl" = "出站测试 URL"
|
||||
"outboundTestUrlDesc" = "测试出站连接时使用的 URL"
|
||||
"Torrent" = "屏蔽 BitTorrent 协议"
|
||||
"Inbounds" = "入站规则"
|
||||
"InboundsDesc" = "接受来自特定客户端的流量"
|
||||
|
|
|
|||
|
|
@ -460,6 +460,8 @@
|
|||
"FreedomStrategyDesc" = "設定 Freedom 協議中網路的輸出策略"
|
||||
"RoutingStrategy" = "配置路由域策略"
|
||||
"RoutingStrategyDesc" = "設定 DNS 解析的整體路由策略"
|
||||
"outboundTestUrl" = "出站測試 URL"
|
||||
"outboundTestUrlDesc" = "測試出站連線時使用的 URL"
|
||||
"Torrent" = "遮蔽 BitTorrent 協議"
|
||||
"Inbounds" = "入站規則"
|
||||
"InboundsDesc" = "接受來自特定客戶端的流量"
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ func BroadcastInbounds(inbounds any) {
|
|||
}
|
||||
|
||||
// BroadcastOutbounds broadcasts outbounds list update to all connected clients
|
||||
func BroadcastOutbounds(outbounds interface{}) {
|
||||
func BroadcastOutbounds(outbounds any) {
|
||||
hub := GetHub()
|
||||
if hub != nil {
|
||||
hub.Broadcast(MessageTypeOutbounds, outbounds)
|
||||
|
|
|
|||
8
x-ui.sh
8
x-ui.sh
|
|
@ -2062,11 +2062,15 @@ SSH_port_forwarding() {
|
|||
)
|
||||
local server_ip=""
|
||||
for ip_address in "${URL_lists[@]}"; do
|
||||
server_ip=$(curl -s --max-time 3 "${ip_address}" 2>/dev/null | tr -d '[:space:]')
|
||||
if [[ -n "${server_ip}" ]]; then
|
||||
local response=$(curl -s -w "\n%{http_code}" --max-time 3 "${ip_address}" 2>/dev/null)
|
||||
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
|
||||
fi
|
||||
done
|
||||
|
||||
local existing_webBasePath=$(${xui_folder}/x-ui setting -show true | grep -Eo 'webBasePath: .+' | awk '{print $2}')
|
||||
local existing_port=$(${xui_folder}/x-ui setting -show true | grep -Eo 'port: .+' | awk '{print $2}')
|
||||
local existing_listenIP=$(${xui_folder}/x-ui setting -getListen true | grep -Eo 'listenIP: .+' | awk '{print $2}')
|
||||
|
|
|
|||
|
|
@ -110,6 +110,15 @@ func NewProcess(xrayConfig *Config) *Process {
|
|||
return p
|
||||
}
|
||||
|
||||
// NewTestProcess creates a new Xray process that uses a specific config file path.
|
||||
// Used for test runs (e.g. outbound test) so the main config.json is not overwritten.
|
||||
// The config file at configPath is removed when the process is stopped.
|
||||
func NewTestProcess(xrayConfig *Config, configPath string) *Process {
|
||||
p := &Process{newTestProcess(xrayConfig, configPath)}
|
||||
runtime.SetFinalizer(p, stopProcess)
|
||||
return p
|
||||
}
|
||||
|
||||
type process struct {
|
||||
cmd *exec.Cmd
|
||||
|
||||
|
|
@ -118,10 +127,11 @@ type process struct {
|
|||
|
||||
onlineClients []string
|
||||
|
||||
config *Config
|
||||
logWriter *LogWriter
|
||||
exitErr error
|
||||
startTime time.Time
|
||||
config *Config
|
||||
configPath string // if set, use this path instead of GetConfigPath() and remove on Stop
|
||||
logWriter *LogWriter
|
||||
exitErr error
|
||||
startTime time.Time
|
||||
}
|
||||
|
||||
// newProcess creates a new internal process struct for Xray.
|
||||
|
|
@ -134,6 +144,13 @@ func newProcess(config *Config) *process {
|
|||
}
|
||||
}
|
||||
|
||||
// newTestProcess creates a process that writes and runs with a specific config path.
|
||||
func newTestProcess(config *Config, configPath string) *process {
|
||||
p := newProcess(config)
|
||||
p.configPath = configPath
|
||||
return p
|
||||
}
|
||||
|
||||
// IsRunning returns true if the Xray process is currently running.
|
||||
func (p *process) IsRunning() bool {
|
||||
if p.cmd == nil || p.cmd.Process == nil {
|
||||
|
|
@ -238,6 +255,9 @@ func (p *process) Start() (err error) {
|
|||
}
|
||||
|
||||
configPath := GetConfigPath()
|
||||
if p.configPath != "" {
|
||||
configPath = p.configPath
|
||||
}
|
||||
err = os.WriteFile(configPath, data, fs.ModePerm)
|
||||
if err != nil {
|
||||
return common.NewErrorf("Failed to write configuration file: %v", err)
|
||||
|
|
@ -278,6 +298,16 @@ func (p *process) Stop() error {
|
|||
return errors.New("xray is not running")
|
||||
}
|
||||
|
||||
// Remove temporary config file used for test runs so main config is never touched
|
||||
if p.configPath != "" {
|
||||
if p.configPath != GetConfigPath() {
|
||||
// Check if file exists before removing
|
||||
if _, err := os.Stat(p.configPath); err == nil {
|
||||
_ = os.Remove(p.configPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
return p.cmd.Process.Kill()
|
||||
} else {
|
||||
|
|
|
|||
Loading…
Reference in a new issue