Compare commits

..

20 commits
v2.8.9 ... main

Author SHA1 Message Date
MHSanaei
e5c0fe3edf
bug fix #3785
Some checks failed
Release 3X-UI / build (386) (push) Has been cancelled
Release 3X-UI / build (amd64) (push) Has been cancelled
Release 3X-UI / build (arm64) (push) Has been cancelled
Release 3X-UI / build (armv5) (push) Has been cancelled
Release 3X-UI / build (armv6) (push) Has been cancelled
Release 3X-UI / build (armv7) (push) Has been cancelled
Release 3X-UI / build (s390x) (push) Has been cancelled
Release 3X-UI / Build for Windows (push) Has been cancelled
2026-02-11 22:21:09 +01:00
MHSanaei
f4057989f5
Require HTTP 200 from curl before using IP
Some checks are pending
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (push) Waiting to run
Replace simple curl+trim checks with a response+http_code parse to ensure the remote URL returns HTTP 200 and a non-empty body before assigning server_ip. Changes applied to install.sh, update.sh and x-ui.sh: use curl -w to append the status code, extract http_code and ip_result, and only set server_ip when http_code == 200 and ip_result is non-empty. This makes the IP discovery more robust against error pages or partial responses while keeping the existing timeout behavior.
2026-02-11 21:32:23 +01:00
MHSanaei
84013b0b3f
v2.8.10 2026-02-11 18:21:43 +01:00
MHSanaei
511adffc5b
Remove allowInsecure
Remove the deprecated `allowInsecure`
2026-02-11 18:21:23 +01:00
bakatrouble
fc6344b840
Fix ipv6 hostname parsing for subscriptions (#3782) 2026-02-11 15:33:53 +01:00
emirjorge
b3555ce1b8
Update translate.es_ES.toml (#3766)
Some checks failed
Release 3X-UI / build (386) (push) Has been cancelled
Release 3X-UI / build (amd64) (push) Has been cancelled
Release 3X-UI / build (arm64) (push) Has been cancelled
Release 3X-UI / build (armv5) (push) Has been cancelled
Release 3X-UI / build (armv6) (push) Has been cancelled
Release 3X-UI / build (armv7) (push) Has been cancelled
Release 3X-UI / build (s390x) (push) Has been cancelled
Release 3X-UI / Build for Windows (push) Has been cancelled
Fix some trasnslations :)
2026-02-09 23:40:03 +01:00
MHSanaei
c2f409c3c4
fix security issue 2026-02-09 23:36:10 +01:00
Nebulosa
0994f8756f
refactor: set default ProfileUrl (#3773) 2026-02-09 21:45:25 +01:00
surbiks
4779939424
Add url speed test for outbound (#3767)
* add outbound testing functionality with configurable test URL

* use no kernel tun for conflict errors
2026-02-09 21:43:17 +01:00
MHSanaei
4a455aa532
Xray Core v26.2.6 and dependency updates
Some checks are pending
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (push) Waiting to run
Update Xray download URLs to v26.2.6 in the GitHub Actions release workflow and DockerInit script. Bump Go toolchain to 1.25.7 and refresh several module versions (telego, xtls/xray-core, klauspost/compress, pires/go-proxyproto, golang.org/x/arch, golang.org/x/sys, google.golang.org/genproto, etc.). Update go.sum to match the new dependency versions.
2026-02-09 12:49:32 +01:00
Nebulosa
25f64738e4
refactor: set header only if it not empty (#3763)
Some checks failed
Release 3X-UI / build (386) (push) Has been cancelled
Release 3X-UI / build (amd64) (push) Has been cancelled
Release 3X-UI / build (arm64) (push) Has been cancelled
Release 3X-UI / build (armv5) (push) Has been cancelled
Release 3X-UI / build (armv6) (push) Has been cancelled
Release 3X-UI / build (armv7) (push) Has been cancelled
Release 3X-UI / build (s390x) (push) Has been cancelled
Release 3X-UI / Build for Windows (push) Has been cancelled
2026-02-07 23:01:05 +01:00
Sanaei
5bb87fd3d4
fix : Uncontrolled data used in path expression
Co-Authored-By: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-02-07 22:54:40 +01:00
Mojtaba Arezoomand
491e3f9f8b
feat: add openssl to dockerfile (#3762) 2026-02-07 22:30:03 +01:00
Aung Ye Zaw
d8fb09faae
feat: implement 'last IP wins' policy for IP limitation (#3735)
Some checks failed
Release 3X-UI / build (386) (push) Has been cancelled
Release 3X-UI / build (amd64) (push) Has been cancelled
Release 3X-UI / build (arm64) (push) Has been cancelled
Release 3X-UI / build (armv5) (push) Has been cancelled
Release 3X-UI / build (armv6) (push) Has been cancelled
Release 3X-UI / build (armv7) (push) Has been cancelled
Release 3X-UI / build (s390x) (push) Has been cancelled
Release 3X-UI / Build for Windows (push) Has been cancelled
- Add timestamp tracking for each client IP address
- Sort IPs by connection time (newest first) instead of alphabetically
- Automatically disconnect old connections when IP limit exceeded
- Keep only the most recent N IPs based on LimitIP setting
- Force disconnection via Xray API (RemoveUser + AddUser)
- Prevents account sharing while allowing legitimate network switching
- Log format: [LIMIT_IP] Email = user@example.com || Disconnecting OLD IP = 1.2.3.4 || Timestamp = 1738521234

This ensures users can seamlessly switch between networks (mobile/WiFi)
and the system maintains connections from their most recent IPs only.

Fixes account sharing prevention for VPN providers selling per-IP licenses.

Co-authored-by: Aung Ye Zaw <zaw.a.y@phluid.world>
2026-02-04 00:38:11 +01:00
MHSanaei
f87c68ea68
Add workflow to clean old GitHub Actions caches
Some checks are pending
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (push) Waiting to run
Adds a scheduled GitHub Actions workflow (.github/workflows/cleanup_caches.yml) that runs weekly (and via workflow_dispatch) to delete Actions caches not accessed in the last 3 days. The job uses the gh CLI with the repository token and actions: write permission to list caches, filter by last_accessed_at against a 3-day cutoff, and delete matching cache IDs.
2026-02-03 00:19:44 +01:00
Ebrahim Tahernejad
687e8cf1ba
[Windows] Use MSYS2 to fix the runtime CGO problem (#3689)
* Use MSYS2 to fix the runtime CGO problem

* macOS build workflow

* Remove macOS build steps and update Windows packaging

Removed macOS build steps from the release workflow and updated Windows packaging step.

* Rename step to copy and download resources
2026-02-02 23:26:04 +01:00
Nebulosa
03f04194f2
Update geofiles according 304 http respond (#3690)
* feat: enhance geofile update process with conditional GET and modification time handling

* style: improve formatting in UpdateGeofile function
2026-02-02 23:20:57 +01:00
Alimpo
248700a8a3
fix: trim whitespace from comma-separated list values in routing rules (#3734) 2026-02-02 23:19:30 +01:00
MHSanaei
ff128a7275
Xray Core v26.2.2 2026-02-02 17:57:56 +01:00
MHSanaei
e8d2973be7
Finalmask: Add XICMP 2026-02-02 17:50:30 +01:00
48 changed files with 2150 additions and 850 deletions

155
.github/copilot-instructions.md vendored Normal file
View 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
View 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

View file

@ -89,7 +89,7 @@ jobs:
cd x-ui/bin cd x-ui/bin
# Download dependencies # Download dependencies
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.1.31/" Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.2.6/"
if [ "${{ matrix.platform }}" == "amd64" ]; then if [ "${{ matrix.platform }}" == "amd64" ]; then
wget -q ${Xray_URL}Xray-linux-64.zip wget -q ${Xray_URL}Xray-linux-64.zip
unzip Xray-linux-64.zip unzip Xray-linux-64.zip
@ -173,21 +173,42 @@ jobs:
go-version-file: go.mod go-version-file: go.mod
check-latest: true check-latest: true
- name: Build 3X-UI for Windows - name: Install MSYS2
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 shell: pwsh
run: | 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 mkdir x-ui
Copy-Item xui-release.exe x-ui\ Copy-Item xui-release.exe x-ui\x-ui.exe
mkdir x-ui\bin mkdir x-ui\bin
cd x-ui\bin cd x-ui\bin
# Download Xray for Windows # Download Xray for Windows
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.1.31/" $Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.2.6/"
Invoke-WebRequest -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip" Invoke-WebRequest -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath . Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
Remove-Item "Xray-windows-64.zip" Remove-Item "Xray-windows-64.zip"

View file

@ -27,7 +27,7 @@ case $1 in
esac esac
mkdir -p build/bin mkdir -p build/bin
cd build/bin cd build/bin
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.1.31/Xray-linux-${ARCH}.zip" curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.2.6/Xray-linux-${ARCH}.zip"
unzip "Xray-linux-${ARCH}.zip" unzip "Xray-linux-${ARCH}.zip"
rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
mv xray "xray-linux-${FNAME}" mv xray "xray-linux-${FNAME}"

View file

@ -30,7 +30,8 @@ RUN apk add --no-cache --update \
tzdata \ tzdata \
fail2ban \ fail2ban \
bash \ bash \
curl curl \
openssl
COPY --from=builder /app/build/ /app/ COPY --from=builder /app/build/ /app/
COPY --from=builder /app/DockerEntrypoint.sh /app/ COPY --from=builder /app/DockerEntrypoint.sh /app/

View file

@ -1 +1 @@
2.8.9 2.8.10

32
go.mod
View file

@ -1,6 +1,6 @@
module github.com/mhsanaei/3x-ui/v2 module github.com/mhsanaei/3x-ui/v2
go 1.25.6 go 1.25.7
require ( require (
github.com/gin-contrib/gzip v1.2.5 github.com/gin-contrib/gzip v1.2.5
@ -11,7 +11,7 @@ require (
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/mymmrac/telego v1.5.0 github.com/mymmrac/telego v1.6.0
github.com/nicksnyder/go-i18n/v2 v2.6.1 github.com/nicksnyder/go-i18n/v2 v2.6.1
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
github.com/pelletier/go-toml/v2 v2.2.4 github.com/pelletier/go-toml/v2 v2.2.4
@ -20,11 +20,11 @@ require (
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/valyala/fasthttp v1.69.0 github.com/valyala/fasthttp v1.69.0
github.com/xlzd/gotp v0.1.0 github.com/xlzd/gotp v0.1.0
github.com/xtls/xray-core v1.260131.0 github.com/xtls/xray-core v1.260206.0
go.uber.org/atomic v1.11.0 go.uber.org/atomic v1.11.0
golang.org/x/crypto v0.47.0 golang.org/x/crypto v0.48.0
golang.org/x/sys v0.40.0 golang.org/x/sys v0.41.0
golang.org/x/text v0.33.0 golang.org/x/text v0.34.0
google.golang.org/grpc v1.78.0 google.golang.org/grpc v1.78.0
gorm.io/driver/sqlite v1.6.0 gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1 gorm.io/gorm v1.31.1
@ -40,7 +40,7 @@ require (
github.com/cloudflare/circl v1.6.3 // indirect github.com/cloudflare/circl v1.6.3 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/ebitengine/purego v0.9.1 // indirect github.com/ebitengine/purego v0.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/gin-contrib/sse v1.1.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect
@ -57,16 +57,16 @@ require (
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/juju/ratelimit v1.0.2 // indirect github.com/juju/ratelimit v1.0.2 // indirect
github.com/klauspost/compress v1.18.3 // indirect github.com/klauspost/compress v1.18.4 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.33 // indirect github.com/mattn/go-sqlite3 v1.14.34 // indirect
github.com/miekg/dns v1.1.72 // indirect github.com/miekg/dns v1.1.72 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pires/go-proxyproto v0.9.2 // indirect github.com/pires/go-proxyproto v0.11.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect
@ -85,16 +85,16 @@ require (
github.com/xtls/reality v0.0.0-20251116175510-cd53f7d50237 // indirect github.com/xtls/reality v0.0.0-20251116175510-cd53f7d50237 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/arch v0.23.0 // indirect golang.org/x/arch v0.24.0 // indirect
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect golang.org/x/exp v0.0.0-20260209203927-2842357ff358 // indirect
golang.org/x/mod v0.32.0 // indirect golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.49.0 // indirect golang.org/x/net v0.50.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/time v0.14.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/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
lukechampine.com/blake3 v1.4.1 // indirect lukechampine.com/blake3 v1.4.1 // indirect

60
go.sum
View file

@ -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/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 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= 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.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344 h1:Arcl6UOIS/kgO2nW3A65HN+7CMjSDP/gofXL4CZt1V4= github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344 h1:Arcl6UOIS/kgO2nW3A65HN+7CMjSDP/gofXL4CZt1V4=
github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344/go.mod h1:GIjDIg/heH5DOkXY3YJ/wNhfHsQHoXGjl8G8amsYQ1I= github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344/go.mod h1:GIjDIg/heH5DOkXY3YJ/wNhfHsQHoXGjl8G8amsYQ1I=
github.com/gin-contrib/gzip v1.2.5 h1:fIZs0S+l17pIu1P5XRJOo/YNqfIuPCrZZ3TWB7pjckI= github.com/gin-contrib/gzip v1.2.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/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/juju/ratelimit v1.0.2 h1:sRxmtRiajbvrcLQT7S+JbqU0ntsb9W2yhSdNN8tWfaI= github.com/juju/ratelimit v1.0.2 h1:sRxmtRiajbvrcLQT7S+JbqU0ntsb9W2yhSdNN8tWfaI=
github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk= github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk=
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= 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 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@ -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/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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 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 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@ -128,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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mymmrac/telego v1.5.0 h1:VjBDZcSpEQim1Y3JX2WCsF/PJqOA2DKfZknXUvtKCnw= github.com/mymmrac/telego v1.6.0 h1:Zc8rgyHozvd/7ZgyrigyHdAF9koHYMfilYfyB6wlFC0=
github.com/mymmrac/telego v1.5.0/go.mod h1:MDYHIeT68tURdcwH4SNCQQ+0xBC3u6wOcH2hBpa4Ip0= 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 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA= github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88= github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88=
@ -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 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 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 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.11.0 h1:gUQpS85X/VJMdUsYyEgyn59uLJvGqPhJV5YvG68wXH4=
github.com/pires/go-proxyproto v0.9.2/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= github.com/pires/go-proxyproto v0.11.0/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
@ -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/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 h1:UXjrmniKlY+ZbIqpN91lejB3pszQQQRVu1vqH/p/aGM=
github.com/xtls/reality v0.0.0-20251116175510-cd53f7d50237/go.mod h1:vbHCV/3VWUvy1oKvTxxWJRPEWSeR1sYgQHIh6u/JiZQ= 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.260206.0 h1:gY8IV6u76CW93txL9QmacgZ0Udxr2Q3e9qUxXAhdHqI=
github.com/xtls/xray-core v1.260131.0/go.mod h1:cxzYFZrxu1B1NtPjHsqv4UzgDvRA71mV4rXYH4KtO7Q= 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 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
@ -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= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg= golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= golang.org/x/exp v0.0.0-20260209203927-2842357ff358 h1:kpfSV7uLwKJbFSEgNhWzGSL47NDSF/5pYYQw1V0ub6c=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= golang.org/x/exp v0.0.0-20260209203927-2842357ff358/go.mod h1:R3t0oliuryB5eenPWl3rrQxwnNM3WTwnsRZZiXLAAW8=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= 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 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -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.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= 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 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= 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.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= 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 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A= golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
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/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= 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/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=

View file

@ -654,8 +654,11 @@ config_after_install() {
) )
local server_ip="" local server_ip=""
for ip_address in "${URL_lists[@]}"; do for ip_address in "${URL_lists[@]}"; do
server_ip=$(curl -s --max-time 3 "${ip_address}" 2>/dev/null | tr -d '[:space:]') local response=$(curl -s -w "\n%{http_code}" --max-time 3 "${ip_address}" 2>/dev/null)
if [[ -n "${server_ip}" ]]; then local http_code=$(echo "$response" | tail -n1)
local ip_result=$(echo "$response" | head -n-1 | tr -d '[:space:]')
if [[ "${http_code}" == "200" && -n "${ip_result}" ]]; then
server_ip="${ip_result}"
break break
fi fi
done done

View file

@ -3,8 +3,8 @@ package sub
import ( import (
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"strings"
"strconv" "strconv"
"strings"
"github.com/mhsanaei/3x-ui/v2/config" "github.com/mhsanaei/3x-ui/v2/config"
@ -64,8 +64,8 @@ func NewSUBController(
subEncrypt: encrypt, subEncrypt: encrypt,
updateInterval: update, updateInterval: update,
subService: sub, subService: sub,
subJsonService: NewSubJsonService(jsonFragment, jsonNoise, jsonMux, jsonRules, sub), subJsonService: NewSubJsonService(jsonFragment, jsonNoise, jsonMux, jsonRules, sub),
} }
a.initRouter(g) a.initRouter(g)
return a return a
@ -143,7 +143,11 @@ func (a *SUBController) subs(c *gin.Context) {
// Add headers // Add headers
header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000) header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, a.subProfileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules) profileUrl := a.subProfileUrl
if profileUrl == "" {
profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
}
a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules)
if a.subEncrypt { if a.subEncrypt {
c.String(200, base64.StdEncoding.EncodeToString([]byte(result))) c.String(200, base64.StdEncoding.EncodeToString([]byte(result)))
@ -156,13 +160,17 @@ func (a *SUBController) subs(c *gin.Context) {
// subJsons handles HTTP requests for JSON subscription configurations. // subJsons handles HTTP requests for JSON subscription configurations.
func (a *SUBController) subJsons(c *gin.Context) { func (a *SUBController) subJsons(c *gin.Context) {
subId := c.Param("subid") subId := c.Param("subid")
_, host, _, _ := a.subService.ResolveRequest(c) scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
jsonSub, header, err := a.subJsonService.GetJson(subId, host) jsonSub, header, err := a.subJsonService.GetJson(subId, host)
if err != nil || len(jsonSub) == 0 { if err != nil || len(jsonSub) == 0 {
c.String(400, "Error!") c.String(400, "Error!")
} else { } else {
// Add headers // 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) 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. // ApplyCommonHeaders sets common HTTP headers for subscription responses including user info, update interval, and profile title.
func (a *SUBController) ApplyCommonHeaders( func (a *SUBController) ApplyCommonHeaders(
c *gin.Context, c *gin.Context,
header, header,
updateInterval, updateInterval,
profileTitle string, profileTitle string,
profileSupportUrl string, profileSupportUrl string,
profileUrl string, profileUrl string,
profileAnnounce string, profileAnnounce string,
profileEnableRouting bool, profileEnableRouting bool,
profileRoutingRules string, profileRoutingRules string,
) { ) {
c.Writer.Header().Set("Subscription-Userinfo", header) c.Writer.Header().Set("Subscription-Userinfo", header)
c.Writer.Header().Set("Profile-Update-Interval", updateInterval) c.Writer.Header().Set("Profile-Update-Interval", updateInterval)
c.Writer.Header().Set("Profile-Title", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileTitle)))
c.Writer.Header().Set("Support-Url", profileSupportUrl) //Basics
c.Writer.Header().Set("Profile-Web-Page-Url", profileUrl) if profileTitle != "" {
c.Writer.Header().Set("Announce", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileAnnounce))) 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-Enable", strconv.FormatBool(profileEnableRouting))
c.Writer.Header().Set("Routing", profileRoutingRules) if profileRoutingRules != "" {
c.Writer.Header().Set("Routing", profileRoutingRules)
}
} }

View file

@ -253,9 +253,6 @@ func (s *SubJsonService) tlsData(tData map[string]any) map[string]any {
tlsData["serverName"] = tData["serverName"] tlsData["serverName"] = tData["serverName"]
tlsData["alpn"] = tData["alpn"] tlsData["alpn"] = tData["alpn"]
if allowInsecure, ok := tlsClientSettings["allowInsecure"].(bool); ok {
tlsData["allowInsecure"] = allowInsecure
}
if fingerprint, ok := tlsClientSettings["fingerprint"].(string); ok { if fingerprint, ok := tlsClientSettings["fingerprint"].(string); ok {
tlsData["fingerprint"] = fingerprint tlsData["fingerprint"] = fingerprint
} }

View file

@ -270,9 +270,6 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok { if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok {
obj["fp"], _ = fpValue.(string) 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) newSecurity, _ := ep["forceTls"].(string)
newObj := map[string]any{} newObj := map[string]any{}
for key, value := range obj { 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 newObj[key] = value
} }
} }
@ -431,11 +428,6 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok { if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok {
params["fp"], _ = fpValue.(string) 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 { 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() q := url.Query()
for k, v := range params { 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) 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 { if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok {
params["fp"], _ = fpValue.(string) 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() q := url.Query()
for k, v := range params { 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) 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 { if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok {
params["fp"], _ = fpValue.(string) 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() q := url.Query()
for k, v := range params { 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) q.Add(k, v)
} }
} }

View file

@ -687,8 +687,11 @@ config_after_update() {
) )
local server_ip="" local server_ip=""
for ip_address in "${URL_lists[@]}"; do for ip_address in "${URL_lists[@]}"; do
server_ip=$(${curl_bin} -s --max-time 3 "${ip_address}" 2>/dev/null | tr -d '[:space:]') local response=$(curl -s -w "\n%{http_code}" --max-time 3 "${ip_address}" 2>/dev/null)
if [[ -n "${server_ip}" ]]; then local http_code=$(echo "$response" | tail -n1)
local ip_result=$(echo "$response" | head -n-1 | tr -d '[:space:]')
if [[ "${http_code}" == "200" && -n "${ip_result}" ]]; then
server_ip="${ip_result}"
break break
fi fi
done done

View file

@ -635,7 +635,7 @@ class TlsStreamSettings extends XrayCommonClass {
} }
if (!ObjectUtil.isEmpty(json.settings)) { 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( return new TlsStreamSettings(
json.serverName, json.serverName,
@ -738,25 +738,21 @@ TlsStreamSettings.Cert = class extends XrayCommonClass {
TlsStreamSettings.Settings = class extends XrayCommonClass { TlsStreamSettings.Settings = class extends XrayCommonClass {
constructor( constructor(
allowInsecure = false,
fingerprint = UTLS_FINGERPRINT.UTLS_CHROME, fingerprint = UTLS_FINGERPRINT.UTLS_CHROME,
echConfigList = '', echConfigList = '',
) { ) {
super(); super();
this.allowInsecure = allowInsecure;
this.fingerprint = fingerprint; this.fingerprint = fingerprint;
this.echConfigList = echConfigList; this.echConfigList = echConfigList;
} }
static fromJson(json = {}) { static fromJson(json = {}) {
return new TlsStreamSettings.Settings( return new TlsStreamSettings.Settings(
json.allowInsecure,
json.fingerprint, json.fingerprint,
json.echConfigList, json.echConfigList,
); );
} }
toJson() { toJson() {
return { return {
allowInsecure: this.allowInsecure,
fingerprint: this.fingerprint, fingerprint: this.fingerprint,
echConfigList: this.echConfigList echConfigList: this.echConfigList
}; };
@ -967,7 +963,7 @@ class SockoptStreamSettings extends XrayCommonClass {
} }
} }
class FinalMask extends XrayCommonClass { class UdpMask extends XrayCommonClass {
constructor(type = 'salamander', settings = {}) { constructor(type = 'salamander', settings = {}) {
super(); super();
this.type = type; this.type = type;
@ -982,6 +978,8 @@ class FinalMask extends XrayCommonClass {
case 'header-dns': case 'header-dns':
case 'xdns': case 'xdns':
return { domain: settings.domain || '' }; return { domain: settings.domain || '' };
case 'xicmp':
return { ip: settings.ip || '', id: settings.id ?? 0 };
case 'mkcp-original': case 'mkcp-original':
case 'header-dtls': case 'header-dtls':
case 'header-srtp': case 'header-srtp':
@ -995,20 +993,35 @@ class FinalMask extends XrayCommonClass {
} }
static fromJson(json = {}) { static fromJson(json = {}) {
return new FinalMask( return new UdpMask(
json.type || 'salamander', json.type || 'salamander',
json.settings || {} json.settings || {}
); );
} }
toJson() { toJson() {
const result = { return {
type: this.type 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(), grpcSettings = new GrpcStreamSettings(),
httpupgradeSettings = new HTTPUpgradeStreamSettings(), httpupgradeSettings = new HTTPUpgradeStreamSettings(),
xhttpSettings = new xHTTPStreamSettings(), xhttpSettings = new xHTTPStreamSettings(),
finalmask = { udp: [] }, finalmask = new FinalMaskStreamSettings(),
sockopt = undefined, sockopt = undefined,
) { ) {
super(); super();
@ -1044,10 +1057,7 @@ class StreamSettings extends XrayCommonClass {
} }
addUdpMask(type = 'salamander') { addUdpMask(type = 'salamander') {
if (!this.finalmask.udp) { this.finalmask.udp.push(new UdpMask(type));
this.finalmask.udp = [];
}
this.finalmask.udp.push(new FinalMask(type));
} }
delUdpMask(index) { delUdpMask(index) {
@ -1056,6 +1066,10 @@ class StreamSettings extends XrayCommonClass {
} }
} }
get hasFinalMask() {
return this.finalmask.udp && this.finalmask.udp.length > 0;
}
get isTls() { get isTls() {
return this.security === "tls"; return this.security === "tls";
} }
@ -1090,14 +1104,6 @@ class StreamSettings extends XrayCommonClass {
} }
static fromJson(json = {}) { 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( return new StreamSettings(
json.network, json.network,
json.security, json.security,
@ -1110,7 +1116,7 @@ class StreamSettings extends XrayCommonClass {
GrpcStreamSettings.fromJson(json.grpcSettings), GrpcStreamSettings.fromJson(json.grpcSettings),
HTTPUpgradeStreamSettings.fromJson(json.httpupgradeSettings), HTTPUpgradeStreamSettings.fromJson(json.httpupgradeSettings),
xHTTPStreamSettings.fromJson(json.xhttpSettings), xHTTPStreamSettings.fromJson(json.xhttpSettings),
finalmask, FinalMaskStreamSettings.fromJson(json.finalmask),
SockoptStreamSettings.fromJson(json.sockopt), SockoptStreamSettings.fromJson(json.sockopt),
); );
} }
@ -1129,9 +1135,7 @@ class StreamSettings extends XrayCommonClass {
grpcSettings: network === 'grpc' ? this.grpc.toJson() : undefined, grpcSettings: network === 'grpc' ? this.grpc.toJson() : undefined,
httpupgradeSettings: network === 'httpupgrade' ? this.httpupgrade.toJson() : undefined, httpupgradeSettings: network === 'httpupgrade' ? this.httpupgrade.toJson() : undefined,
xhttpSettings: network === 'xhttp' ? this.xhttp.toJson() : undefined, xhttpSettings: network === 'xhttp' ? this.xhttp.toJson() : undefined,
finalmask: (this.finalmask.udp && this.finalmask.udp.length > 0) ? { finalmask: this.hasFinalMask ? this.finalmask.toJson() : undefined,
udp: this.finalmask.udp.map(mask => mask.toJson())
} : undefined,
sockopt: this.sockopt != undefined ? this.sockopt.toJson() : undefined, sockopt: this.sockopt != undefined ? this.sockopt.toJson() : undefined,
}; };
} }
@ -1302,14 +1306,6 @@ class Inbound extends XrayCommonClass {
return null; return null;
} }
get kcpType() {
return this.stream.kcp.type;
}
get kcpSeed() {
return this.stream.kcp.seed;
}
get serviceName() { get serviceName() {
return this.stream.grpc.serviceName; return this.stream.grpc.serviceName;
} }
@ -1386,8 +1382,6 @@ class Inbound extends XrayCommonClass {
} }
} else if (network === 'kcp') { } else if (network === 'kcp') {
const kcp = this.stream.kcp; const kcp = this.stream.kcp;
obj.type = kcp.type;
obj.path = kcp.seed;
} else if (network === 'ws') { } else if (network === 'ws') {
const ws = this.stream.ws; const ws = this.stream.ws;
obj.path = ws.path; obj.path = ws.path;
@ -1419,9 +1413,6 @@ class Inbound extends XrayCommonClass {
if (this.stream.tls.alpn.length > 0) { if (this.stream.tls.alpn.length > 0) {
obj.alpn = this.stream.tls.alpn.join(','); 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)); return 'vmess://' + Base64.encode(JSON.stringify(obj, null, 2));
@ -1450,8 +1441,6 @@ class Inbound extends XrayCommonClass {
break; break;
case "kcp": case "kcp":
const kcp = this.stream.kcp; const kcp = this.stream.kcp;
params.set("headerType", kcp.type);
params.set("seed", kcp.seed);
break; break;
case "ws": case "ws":
const ws = this.stream.ws; const ws = this.stream.ws;
@ -1484,9 +1473,6 @@ class Inbound extends XrayCommonClass {
if (this.stream.isTls) { if (this.stream.isTls) {
params.set("fp", this.stream.tls.settings.fingerprint); params.set("fp", this.stream.tls.settings.fingerprint);
params.set("alpn", this.stream.tls.alpn); params.set("alpn", this.stream.tls.alpn);
if (this.stream.tls.settings.allowInsecure) {
params.set("allowInsecure", "1");
}
if (!ObjectUtil.isEmpty(this.stream.tls.sni)) { if (!ObjectUtil.isEmpty(this.stream.tls.sni)) {
params.set("sni", this.stream.tls.sni); params.set("sni", this.stream.tls.sni);
} }
@ -1555,8 +1541,6 @@ class Inbound extends XrayCommonClass {
break; break;
case "kcp": case "kcp":
const kcp = this.stream.kcp; const kcp = this.stream.kcp;
params.set("headerType", kcp.type);
params.set("seed", kcp.seed);
break; break;
case "ws": case "ws":
const ws = this.stream.ws; const ws = this.stream.ws;
@ -1589,9 +1573,6 @@ class Inbound extends XrayCommonClass {
if (this.stream.isTls) { if (this.stream.isTls) {
params.set("fp", this.stream.tls.settings.fingerprint); params.set("fp", this.stream.tls.settings.fingerprint);
params.set("alpn", this.stream.tls.alpn); 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) { if (this.stream.tls.settings.echConfigList?.length > 0) {
params.set("ech", this.stream.tls.settings.echConfigList); params.set("ech", this.stream.tls.settings.echConfigList);
} }
@ -1636,8 +1617,6 @@ class Inbound extends XrayCommonClass {
break; break;
case "kcp": case "kcp":
const kcp = this.stream.kcp; const kcp = this.stream.kcp;
params.set("headerType", kcp.type);
params.set("seed", kcp.seed);
break; break;
case "ws": case "ws":
const ws = this.stream.ws; const ws = this.stream.ws;
@ -1670,9 +1649,6 @@ class Inbound extends XrayCommonClass {
if (this.stream.isTls) { if (this.stream.isTls) {
params.set("fp", this.stream.tls.settings.fingerprint); params.set("fp", this.stream.tls.settings.fingerprint);
params.set("alpn", this.stream.tls.alpn); 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) { if (this.stream.tls.settings.echConfigList?.length > 0) {
params.set("ech", this.stream.tls.settings.echConfigList); params.set("ech", this.stream.tls.settings.echConfigList);
} }

View file

@ -345,16 +345,14 @@ class TlsStreamSettings extends CommonClass {
serverName = '', serverName = '',
alpn = [], alpn = [],
fingerprint = '', fingerprint = '',
allowInsecure = false,
echConfigList = '', echConfigList = '',
verifyPeerCertByName = 'cloudflare-dns.com', verifyPeerCertByName = '',
pinnedPeerCertSha256 = '', pinnedPeerCertSha256 = '',
) { ) {
super(); super();
this.serverName = serverName; this.serverName = serverName;
this.alpn = alpn; this.alpn = alpn;
this.fingerprint = fingerprint; this.fingerprint = fingerprint;
this.allowInsecure = allowInsecure;
this.echConfigList = echConfigList; this.echConfigList = echConfigList;
this.verifyPeerCertByName = verifyPeerCertByName; this.verifyPeerCertByName = verifyPeerCertByName;
this.pinnedPeerCertSha256 = pinnedPeerCertSha256; this.pinnedPeerCertSha256 = pinnedPeerCertSha256;
@ -365,7 +363,6 @@ class TlsStreamSettings extends CommonClass {
json.serverName, json.serverName,
json.alpn, json.alpn,
json.fingerprint, json.fingerprint,
json.allowInsecure,
json.echConfigList, json.echConfigList,
json.verifyPeerCertByName, json.verifyPeerCertByName,
json.pinnedPeerCertSha256, json.pinnedPeerCertSha256,
@ -377,7 +374,6 @@ class TlsStreamSettings extends CommonClass {
serverName: this.serverName, serverName: this.serverName,
alpn: this.alpn, alpn: this.alpn,
fingerprint: this.fingerprint, fingerprint: this.fingerprint,
allowInsecure: this.allowInsecure,
echConfigList: this.echConfigList, echConfigList: this.echConfigList,
verifyPeerCertByName: this.verifyPeerCertByName, verifyPeerCertByName: this.verifyPeerCertByName,
pinnedPeerCertSha256: this.pinnedPeerCertSha256 pinnedPeerCertSha256: this.pinnedPeerCertSha256
@ -568,7 +564,7 @@ class SockoptStreamSettings extends CommonClass {
} }
} }
class FinalMask extends CommonClass { class UdpMask extends CommonClass {
constructor(type = 'salamander', settings = {}) { constructor(type = 'salamander', settings = {}) {
super(); super();
this.type = type; this.type = type;
@ -596,21 +592,35 @@ class FinalMask extends CommonClass {
} }
static fromJson(json = {}) { static fromJson(json = {}) {
return new FinalMask( return new UdpMask(
json.type || 'salamander', json.type || 'salamander',
json.settings || {} json.settings || {}
); );
} }
toJson() { toJson() {
const result = { return {
type: this.type 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;
} class FinalMaskStreamSettings extends CommonClass {
return result; 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(), httpupgradeSettings = new HttpUpgradeStreamSettings(),
xhttpSettings = new xHTTPStreamSettings(), xhttpSettings = new xHTTPStreamSettings(),
hysteriaSettings = new HysteriaStreamSettings(), hysteriaSettings = new HysteriaStreamSettings(),
finalmask = { udp: [] }, finalmask = new FinalMaskStreamSettings(),
sockopt = undefined, sockopt = undefined,
) { ) {
super(); super();
@ -647,10 +657,7 @@ class StreamSettings extends CommonClass {
} }
addUdpMask(type = 'salamander') { addUdpMask(type = 'salamander') {
if (!this.finalmask.udp) { this.finalmask.udp.push(new UdpMask(type));
this.finalmask.udp = [];
}
this.finalmask.udp.push(new FinalMask(type));
} }
delUdpMask(index) { delUdpMask(index) {
@ -659,6 +666,10 @@ class StreamSettings extends CommonClass {
} }
} }
get hasFinalMask() {
return this.finalmask.udp && this.finalmask.udp.length > 0;
}
get isTls() { get isTls() {
return this.security === 'tls'; return this.security === 'tls';
} }
@ -676,16 +687,6 @@ class StreamSettings extends CommonClass {
} }
static fromJson(json = {}) { static fromJson(json = {}) {
let finalmask = { udp: [] };
if (json.finalmask) {
if (Array.isArray(json.finalmask)) {
// Legacy format: direct array (backward compatibility)
finalmask.udp = json.finalmask.map(mask => FinalMask.fromJson(mask));
} else if (json.finalmask.udp) {
// New format: object with udp array
finalmask.udp = json.finalmask.udp.map(mask => FinalMask.fromJson(mask));
}
}
return new StreamSettings( return new StreamSettings(
json.network, json.network,
json.security, json.security,
@ -698,7 +699,7 @@ class StreamSettings extends CommonClass {
HttpUpgradeStreamSettings.fromJson(json.httpupgradeSettings), HttpUpgradeStreamSettings.fromJson(json.httpupgradeSettings),
xHTTPStreamSettings.fromJson(json.xhttpSettings), xHTTPStreamSettings.fromJson(json.xhttpSettings),
HysteriaStreamSettings.fromJson(json.hysteriaSettings), HysteriaStreamSettings.fromJson(json.hysteriaSettings),
finalmask, FinalMaskStreamSettings.fromJson(json.finalmask),
SockoptStreamSettings.fromJson(json.sockopt), SockoptStreamSettings.fromJson(json.sockopt),
); );
} }
@ -717,9 +718,7 @@ class StreamSettings extends CommonClass {
httpupgradeSettings: network === 'httpupgrade' ? this.httpupgrade.toJson() : undefined, httpupgradeSettings: network === 'httpupgrade' ? this.httpupgrade.toJson() : undefined,
xhttpSettings: network === 'xhttp' ? this.xhttp.toJson() : undefined, xhttpSettings: network === 'xhttp' ? this.xhttp.toJson() : undefined,
hysteriaSettings: network === 'hysteria' ? this.hysteria.toJson() : undefined, hysteriaSettings: network === 'hysteria' ? this.hysteria.toJson() : undefined,
finalmask: (this.finalmask.udp && this.finalmask.udp.length > 0) ? { finalmask: this.hasFinalMask ? this.finalmask.toJson() : undefined,
udp: this.finalmask.udp.map(mask => mask.toJson())
} : undefined,
sockopt: this.sockopt != undefined ? this.sockopt.toJson() : undefined, sockopt: this.sockopt != undefined ? this.sockopt.toJson() : undefined,
}; };
} }
@ -933,8 +932,7 @@ class Outbound extends CommonClass {
stream.tls = new TlsStreamSettings( stream.tls = new TlsStreamSettings(
json.sni, json.sni,
json.alpn ? json.alpn.split(',') : [], json.alpn ? json.alpn.split(',') : [],
json.fp, json.fp);
json.allowInsecure);
} }
const port = json.port * 1; const port = json.port * 1;
@ -975,10 +973,9 @@ class Outbound extends CommonClass {
if (security == 'tls') { if (security == 'tls') {
let fp = url.searchParams.get('fp') ?? 'none'; let fp = url.searchParams.get('fp') ?? 'none';
let alpn = url.searchParams.get('alpn'); let alpn = url.searchParams.get('alpn');
let allowInsecure = url.searchParams.get('allowInsecure');
let sni = url.searchParams.get('sni') ?? ''; let sni = url.searchParams.get('sni') ?? '';
let ech = url.searchParams.get('ech') ?? ''; let ech = url.searchParams.get('ech') ?? '';
stream.tls = new TlsStreamSettings(sni, alpn ? alpn.split(',') : [], fp, allowInsecure == 1, ech); stream.tls = new TlsStreamSettings(sni, alpn ? alpn.split(',') : [], fp, ech);
} }
if (security == 'reality') { if (security == 'reality') {

View file

@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"strconv" "strconv"
"time"
"github.com/mhsanaei/3x-ui/v2/database/model" "github.com/mhsanaei/3x-ui/v2/database/model"
"github.com/mhsanaei/3x-ui/v2/web/service" "github.com/mhsanaei/3x-ui/v2/web/service"
@ -193,6 +194,37 @@ func (a *InboundController) getClientIps(c *gin.Context) {
return return
} }
// Prefer returning a normalized string list for consistent UI rendering
type ipWithTimestamp struct {
IP string `json:"ip"`
Timestamp int64 `json:"timestamp"`
}
var ipsWithTime []ipWithTimestamp
if err := json.Unmarshal([]byte(ips), &ipsWithTime); err == nil && len(ipsWithTime) > 0 {
formatted := make([]string, 0, len(ipsWithTime))
for _, item := range ipsWithTime {
if item.IP == "" {
continue
}
if item.Timestamp > 0 {
ts := time.Unix(item.Timestamp, 0).Local().Format("2006-01-02 15:04:05")
formatted = append(formatted, fmt.Sprintf("%s (%s)", item.IP, ts))
continue
}
formatted = append(formatted, item.IP)
}
jsonObj(c, formatted, nil)
return
}
var oldIps []string
if err := json.Unmarshal([]byte(ips), &oldIps); err == nil && len(oldIps) > 0 {
jsonObj(c, oldIps, nil)
return
}
// If parsing fails, return as string
jsonObj(c, ips, nil) jsonObj(c, ips, nil)
} }

View file

@ -1,6 +1,9 @@
package controller package controller
import ( import (
"encoding/json"
"github.com/mhsanaei/3x-ui/v2/util/common"
"github.com/mhsanaei/3x-ui/v2/web/service" "github.com/mhsanaei/3x-ui/v2/web/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@ -34,9 +37,10 @@ func (a *XraySettingController) initRouter(g *gin.RouterGroup) {
g.POST("/warp/:action", a.warp) g.POST("/warp/:action", a.warp)
g.POST("/update", a.updateSetting) g.POST("/update", a.updateSetting)
g.POST("/resetOutboundsTraffic", a.resetOutboundsTraffic) g.POST("/resetOutboundsTraffic", a.resetOutboundsTraffic)
g.POST("/testOutbound", a.testOutbound)
} }
// getXraySetting retrieves the Xray configuration template and inbound tags. // getXraySetting retrieves the Xray configuration template, inbound tags, and outbound test URL.
func (a *XraySettingController) getXraySetting(c *gin.Context) { func (a *XraySettingController) getXraySetting(c *gin.Context) {
xraySetting, err := a.SettingService.GetXrayConfigTemplate() xraySetting, err := a.SettingService.GetXrayConfigTemplate()
if err != nil { if err != nil {
@ -48,15 +52,36 @@ func (a *XraySettingController) getXraySetting(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err) jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
return return
} }
xrayResponse := "{ \"xraySetting\": " + xraySetting + ", \"inboundTags\": " + inboundTags + " }" outboundTestUrl, _ := a.SettingService.GetXrayOutboundTestUrl()
jsonObj(c, xrayResponse, nil) if outboundTestUrl == "" {
outboundTestUrl = "https://www.google.com/generate_204"
}
xrayResponse := map[string]interface{}{
"xraySetting": json.RawMessage(xraySetting),
"inboundTags": json.RawMessage(inboundTags),
"outboundTestUrl": outboundTestUrl,
}
result, err := json.Marshal(xrayResponse)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
return
}
jsonObj(c, string(result), nil)
} }
// updateSetting updates the Xray configuration settings. // updateSetting updates the Xray configuration settings.
func (a *XraySettingController) updateSetting(c *gin.Context) { func (a *XraySettingController) updateSetting(c *gin.Context) {
xraySetting := c.PostForm("xraySetting") xraySetting := c.PostForm("xraySetting")
err := a.XraySettingService.SaveXraySetting(xraySetting) if err := a.XraySettingService.SaveXraySetting(xraySetting); err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err) jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
return
}
outboundTestUrl := c.PostForm("outboundTestUrl")
if outboundTestUrl == "" {
outboundTestUrl = "https://www.google.com/generate_204"
}
_ = a.SettingService.SetXrayOutboundTestUrl(outboundTestUrl)
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), nil)
} }
// getDefaultXrayConfig retrieves the default Xray configuration. // getDefaultXrayConfig retrieves the default Xray configuration.
@ -118,3 +143,26 @@ func (a *XraySettingController) resetOutboundsTraffic(c *gin.Context) {
} }
jsonObj(c, "", nil) jsonObj(c, "", nil)
} }
// testOutbound tests an outbound configuration and returns the delay/response time.
// Optional form "allOutbounds": JSON array of all outbounds; used to resolve sockopt.dialerProxy dependencies.
func (a *XraySettingController) testOutbound(c *gin.Context) {
outboundJSON := c.PostForm("outbound")
allOutboundsJSON := c.PostForm("allOutbounds")
if outboundJSON == "" {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), common.NewError("outbound parameter is required"))
return
}
// Load the test URL from server settings to prevent SSRF via user-controlled URLs
testURL, _ := a.SettingService.GetXrayOutboundTestUrl()
result, err := a.OutboundService.TestOutbound(outboundJSON, testURL, allOutboundsJSON)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, result, nil)
}

View file

@ -700,14 +700,12 @@
<a-form-item label="ECH Config List"> <a-form-item label="ECH Config List">
<a-input v-model.trim="outbound.stream.tls.echConfigList"></a-input> <a-input v-model.trim="outbound.stream.tls.echConfigList"></a-input>
</a-form-item> </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-form-item label="verify Peer Cert By Name">
<a-input <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>
<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" <a-input v-model.trim="outbound.stream.tls.pinnedPeerCertSha256"
placeholder="Enter SHA256 fingerprints (base64)"> placeholder="Enter SHA256 fingerprints (base64)">
</a-input> </a-input>
@ -772,7 +770,8 @@
<a-switch v-model="outbound.stream.sockopt.tcpFastOpen"></a-switch> <a-switch v-model="outbound.stream.sockopt.tcpFastOpen"></a-switch>
</a-form-item> </a-form-item>
<a-form-item label="Multipath TCP"> <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>
<a-form-item label="Penetrate"> <a-form-item label="Penetrate">
<a-switch v-model="outbound.stream.sockopt.penetrate"></a-switch> <a-switch v-model="outbound.stream.sockopt.penetrate"></a-switch>
@ -799,7 +798,8 @@
</a-form-item> </a-form-item>
<template v-if="outbound.mux.enabled"> <template v-if="outbound.mux.enabled">
<a-form-item label="Concurrency"> <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> :max="1024"></a-input-number>
</a-form-item> </a-form-item>
<a-form-item label="xudp Concurrency"> <a-form-item label="xudp Concurrency">

View file

@ -45,6 +45,9 @@
<a-select-option v-if="inbound.stream.network === 'kcp'" <a-select-option v-if="inbound.stream.network === 'kcp'"
value="mkcp-original"> value="mkcp-original">
mKCP Original</a-select-option> 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 --> <!-- xDNS for TCP/WS/HTTPUpgrade/XHTTP -->
<a-select-option <a-select-option
v-if="['tcp', 'ws', 'httpupgrade', 'xhttp'].includes(inbound.stream.network)" v-if="['tcp', 'ws', 'httpupgrade', 'xhttp'].includes(inbound.stream.network)"
@ -64,6 +67,17 @@
<a-input v-model.trim="mask.settings.domain" <a-input v-model.trim="mask.settings.domain"
placeholder="e.g., www.example.com"></a-input> placeholder="e.g., www.example.com"></a-input>
</a-form-item> </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> </a-form>
</template> </template>
</a-form> </a-form>

View file

@ -58,9 +58,6 @@
]]</a-select-option> ]]</a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
<a-form-item label="Allow Insecure">
<a-switch v-model="inbound.stream.tls.settings.allowInsecure"></a-switch>
</a-form-item>
<a-form-item label="Reject Unknown SNI"> <a-form-item label="Reject Unknown SNI">
<a-switch v-model="inbound.stream.tls.rejectUnknownSni"></a-switch> <a-switch v-model="inbound.stream.tls.rejectUnknownSni"></a-switch>
</a-form-item> </a-form-item>

File diff suppressed because it is too large Load diff

View file

@ -219,14 +219,14 @@
rule = {}; rule = {};
newRule = {}; newRule = {};
rule.type = "field"; rule.type = "field";
rule.domain = value.domain.length > 0 ? value.domain.split(',') : []; rule.domain = value.domain.length > 0 ? value.domain.split(',').map(s => s.trim()) : [];
rule.ip = value.ip.length > 0 ? value.ip.split(',') : []; rule.ip = value.ip.length > 0 ? value.ip.split(',').map(s => s.trim()) : [];
rule.port = value.port; rule.port = value.port;
rule.sourcePort = value.sourcePort; rule.sourcePort = value.sourcePort;
rule.vlessRoute = value.vlessRoute; rule.vlessRoute = value.vlessRoute;
rule.network = value.network; rule.network = value.network;
rule.sourceIP = value.sourceIP.length > 0 ? value.sourceIP.split(',') : []; rule.sourceIP = value.sourceIP.length > 0 ? value.sourceIP.split(',').map(s => s.trim()) : [];
rule.user = value.user.length > 0 ? value.user.split(',') : []; rule.user = value.user.length > 0 ? value.user.split(',').map(s => s.trim()) : [];
rule.inboundTag = value.inboundTag; rule.inboundTag = value.inboundTag;
rule.protocol = value.protocol; rule.protocol = value.protocol;
rule.attrs = Object.fromEntries(value.attrs); rule.attrs = Object.fromEntries(value.attrs);

View file

@ -4,18 +4,22 @@
<a-row :xs="24" :sm="24" :lg="12"> <a-row :xs="24" :sm="24" :lg="12">
<a-alert type="warning" :style="{ textAlign: 'center' }"> <a-alert type="warning" :style="{ textAlign: 'center' }">
<template slot="message"> <template slot="message">
<a-icon type="exclamation-circle" theme="filled" :style="{ color: '#FFA031' }"></a-icon> <a-icon type="exclamation-circle" theme="filled"
:style="{ color: '#FFA031' }"></a-icon>
<span>{{ i18n "pages.xray.generalConfigsDesc" }}</span> <span>{{ i18n "pages.xray.generalConfigsDesc" }}</span>
</template> </template>
</a-alert> </a-alert>
</a-row> </a-row>
<a-setting-list-item paddings="small"> <a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.xray.FreedomStrategy" }}</template> <template #title>{{ i18n "pages.xray.FreedomStrategy" }}</template>
<template #description>{{ i18n "pages.xray.FreedomStrategyDesc" }}</template> <template #description>{{ i18n "pages.xray.FreedomStrategyDesc"
}}</template>
<template #control> <template #control>
<a-select v-model="freedomStrategy" :dropdown-class-name="themeSwitcher.currentTheme" <a-select v-model="freedomStrategy"
:dropdown-class-name="themeSwitcher.currentTheme"
:style="{ width: '100%' }"> :style="{ width: '100%' }">
<a-select-option v-for="s in OutboundDomainStrategies" :value="s"> <a-select-option v-for="s in OutboundDomainStrategies"
:value="s">
<span>[[ s ]]</span> <span>[[ s ]]</span>
</a-select-option> </a-select-option>
</a-select> </a-select>
@ -23,42 +27,63 @@
</a-setting-list-item> </a-setting-list-item>
<a-setting-list-item paddings="small"> <a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.xray.RoutingStrategy" }}</template> <template #title>{{ i18n "pages.xray.RoutingStrategy" }}</template>
<template #description>{{ i18n "pages.xray.RoutingStrategyDesc" }}</template> <template #description>{{ i18n "pages.xray.RoutingStrategyDesc"
}}</template>
<template #control> <template #control>
<a-select v-model="routingStrategy" :dropdown-class-name="themeSwitcher.currentTheme" <a-select v-model="routingStrategy"
:dropdown-class-name="themeSwitcher.currentTheme"
:style="{ width: '100%' }"> :style="{ width: '100%' }">
<a-select-option v-for="s in routingDomainStrategies" :value="s"> <a-select-option v-for="s in routingDomainStrategies"
:value="s">
<span>[[ s ]]</span> <span>[[ s ]]</span>
</a-select-option> </a-select-option>
</a-select> </a-select>
</template> </template>
</a-setting-list-item> </a-setting-list-item>
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.xray.outboundTestUrl" }}</template>
<template #description>{{ i18n "pages.xray.outboundTestUrlDesc"
}}</template>
<template #control>
<a-input v-model="outboundTestUrl"
:placeholder="'https://www.google.com/generate_204'"
:style="{ width: '100%' }"></a-input>
</template>
</a-setting-list-item>
</a-collapse-panel> </a-collapse-panel>
<a-collapse-panel key="2" header='{{ i18n "pages.xray.statistics" }}'> <a-collapse-panel key="2" header='{{ i18n "pages.xray.statistics" }}'>
<a-setting-list-item paddings="small"> <a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.xray.statsInboundUplink" }}</template> <template #title>{{ i18n "pages.xray.statsInboundUplink"
<template #description>{{ i18n "pages.xray.statsInboundUplinkDesc" }}</template> }}</template>
<template #description>{{ i18n "pages.xray.statsInboundUplinkDesc"
}}</template>
<template #control> <template #control>
<a-switch v-model="statsInboundUplink"></a-switch> <a-switch v-model="statsInboundUplink"></a-switch>
</template> </template>
</a-setting-list-item> </a-setting-list-item>
<a-setting-list-item paddings="small"> <a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.xray.statsInboundDownlink" }}</template> <template #title>{{ i18n "pages.xray.statsInboundDownlink"
<template #description>{{ i18n "pages.xray.statsInboundDownlinkDesc" }}</template> }}</template>
<template #description>{{ i18n "pages.xray.statsInboundDownlinkDesc"
}}</template>
<template #control> <template #control>
<a-switch v-model="statsInboundDownlink"></a-switch> <a-switch v-model="statsInboundDownlink"></a-switch>
</template> </template>
</a-setting-list-item> </a-setting-list-item>
<a-setting-list-item paddings="small"> <a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.xray.statsOutboundUplink" }}</template> <template #title>{{ i18n "pages.xray.statsOutboundUplink"
<template #description>{{ i18n "pages.xray.statsOutboundUplinkDesc" }}</template> }}</template>
<template #description>{{ i18n "pages.xray.statsOutboundUplinkDesc"
}}</template>
<template #control> <template #control>
<a-switch v-model="statsOutboundUplink"></a-switch> <a-switch v-model="statsOutboundUplink"></a-switch>
</template> </template>
</a-setting-list-item> </a-setting-list-item>
<a-setting-list-item paddings="small"> <a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.xray.statsOutboundDownlink" }}</template> <template #title>{{ i18n "pages.xray.statsOutboundDownlink"
<template #description>{{ i18n "pages.xray.statsOutboundDownlinkDesc" }}</template> }}</template>
<template #description>{{ i18n
"pages.xray.statsOutboundDownlinkDesc" }}</template>
<template #control> <template #control>
<a-switch v-model="statsOutboundDownlink"></a-switch> <a-switch v-model="statsOutboundDownlink"></a-switch>
</template> </template>
@ -68,16 +93,20 @@
<a-row :xs="24" :sm="24" :lg="12"> <a-row :xs="24" :sm="24" :lg="12">
<a-alert type="warning" :style="{ textAlign: 'center' }"> <a-alert type="warning" :style="{ textAlign: 'center' }">
<template slot="message"> <template slot="message">
<a-icon type="exclamation-circle" theme="filled" :style="{ color: '#FFA031' }"></a-icon> <a-icon type="exclamation-circle" theme="filled"
:style="{ color: '#FFA031' }"></a-icon>
<span>{{ i18n "pages.xray.logConfigsDesc" }}</span> <span>{{ i18n "pages.xray.logConfigsDesc" }}</span>
</template> </template>
</a-alert> </a-alert>
</a-row> </a-row>
<a-setting-list-item paddings="small"> <a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.xray.logLevel" }}</template> <template #title>{{ i18n "pages.xray.logLevel" }}</template>
<template #description>{{ i18n "pages.xray.logLevelDesc" }}</template> <template #description>{{ i18n "pages.xray.logLevelDesc"
}}</template>
<template #control> <template #control>
<a-select v-model="logLevel" :dropdown-class-name="themeSwitcher.currentTheme" :style="{ width: '100%' }"> <a-select v-model="logLevel"
:dropdown-class-name="themeSwitcher.currentTheme"
:style="{ width: '100%' }">
<a-select-option v-for="s in log.loglevel" :value="s"> <a-select-option v-for="s in log.loglevel" :value="s">
<span>[[ s ]]</span> <span>[[ s ]]</span>
</a-select-option> </a-select-option>
@ -86,10 +115,13 @@
</a-setting-list-item> </a-setting-list-item>
<a-setting-list-item paddings="small"> <a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.xray.accessLog" }}</template> <template #title>{{ i18n "pages.xray.accessLog" }}</template>
<template #description>{{ i18n "pages.xray.accessLogDesc" }}</template> <template #description>{{ i18n "pages.xray.accessLogDesc"
}}</template>
<template #control> <template #control>
<a-select v-model="accessLog" :dropdown-class-name="themeSwitcher.currentTheme" :style="{ width: '100%' }"> <a-select v-model="accessLog"
<a-select-option value=''> :dropdown-class-name="themeSwitcher.currentTheme"
:style="{ width: '100%' }">
<a-select-option value>
<span>Empty</span> <span>Empty</span>
</a-select-option> </a-select-option>
<a-select-option v-for="s in log.access" :value="s"> <a-select-option v-for="s in log.access" :value="s">
@ -100,10 +132,13 @@
</a-setting-list-item> </a-setting-list-item>
<a-setting-list-item paddings="small"> <a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.xray.errorLog" }}</template> <template #title>{{ i18n "pages.xray.errorLog" }}</template>
<template #description>{{ i18n "pages.xray.errorLogDesc" }}</template> <template #description>{{ i18n "pages.xray.errorLogDesc"
}}</template>
<template #control> <template #control>
<a-select v-model="errorLog" :dropdown-class-name="themeSwitcher.currentTheme" :style="{ width: '100%' }"> <a-select v-model="errorLog"
<a-select-option value=''> :dropdown-class-name="themeSwitcher.currentTheme"
:style="{ width: '100%' }">
<a-select-option value>
<span>Empty</span> <span>Empty</span>
</a-select-option> </a-select-option>
<a-select-option v-for="s in log.error" :value="s"> <a-select-option v-for="s in log.error" :value="s">
@ -114,11 +149,13 @@
</a-setting-list-item> </a-setting-list-item>
<a-setting-list-item paddings="small"> <a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.xray.maskAddress" }}</template> <template #title>{{ i18n "pages.xray.maskAddress" }}</template>
<template #description>{{ i18n "pages.xray.maskAddressDesc" }}</template> <template #description>{{ i18n "pages.xray.maskAddressDesc"
}}</template>
<template #control> <template #control>
<a-select v-model="maskAddressLog" :dropdown-class-name="themeSwitcher.currentTheme" <a-select v-model="maskAddressLog"
:dropdown-class-name="themeSwitcher.currentTheme"
:style="{ width: '100%' }"> :style="{ width: '100%' }">
<a-select-option value=''> <a-select-option value>
<span>Empty</span> <span>Empty</span>
</a-select-option> </a-select-option>
<a-select-option v-for="s in log.maskAddress" :value="s"> <a-select-option v-for="s in log.maskAddress" :value="s">
@ -139,7 +176,8 @@
<a-row :xs="24" :sm="24" :lg="12"> <a-row :xs="24" :sm="24" :lg="12">
<a-alert type="warning" :style="{ textAlign: 'center' }"> <a-alert type="warning" :style="{ textAlign: 'center' }">
<template slot="message"> <template slot="message">
<a-icon type="exclamation-circle" theme="filled" :style="{ color: '#FFA031' }"></a-icon> <a-icon type="exclamation-circle" theme="filled"
:style="{ color: '#FFA031' }"></a-icon>
<span>{{ i18n "pages.xray.blockConfigsDesc" }}</span> <span>{{ i18n "pages.xray.blockConfigsDesc" }}</span>
</template> </template>
</a-alert> </a-alert>
@ -153,17 +191,21 @@
<a-row :xs="24" :sm="24" :lg="12"> <a-row :xs="24" :sm="24" :lg="12">
<a-alert type="warning" :style="{ textAlign: 'center' }"> <a-alert type="warning" :style="{ textAlign: 'center' }">
<template slot="message"> <template slot="message">
<a-icon type="exclamation-circle" theme="filled" :style="{ color: '#FFA031' }"></a-icon> <a-icon type="exclamation-circle" theme="filled"
<span>{{ i18n "pages.xray.blockConnectionsConfigsDesc" }}</span> :style="{ color: '#FFA031' }"></a-icon>
<span>{{ i18n "pages.xray.blockConnectionsConfigsDesc"
}}</span>
</template> </template>
</a-alert> </a-alert>
</a-row> </a-row>
<a-setting-list-item paddings="small"> <a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.xray.blockips" }}</template> <template #title>{{ i18n "pages.xray.blockips" }}</template>
<template #control> <template #control>
<a-select mode="tags" v-model="blockedIPs" :style="{ width: '100%' }" <a-select mode="tags" v-model="blockedIPs"
:style="{ width: '100%' }"
:dropdown-class-name="themeSwitcher.currentTheme"> :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option :value="p.value" :label="p.label" v-for="p in settingsData.IPsOptions"> <a-select-option :value="p.value" :label="p.label"
v-for="p in settingsData.IPsOptions">
<span>[[ p.label ]]</span> <span>[[ p.label ]]</span>
</a-select-option> </a-select-option>
</a-select> </a-select>
@ -172,28 +214,35 @@
<a-setting-list-item paddings="small"> <a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.xray.blockdomains" }}</template> <template #title>{{ i18n "pages.xray.blockdomains" }}</template>
<template #control> <template #control>
<a-select mode="tags" v-model="blockedDomains" :style="{ width: '100%' }" <a-select mode="tags" v-model="blockedDomains"
:style="{ width: '100%' }"
:dropdown-class-name="themeSwitcher.currentTheme"> :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option :value="p.value" :label="p.label" v-for="p in settingsData.BlockDomainsOptions"> <a-select-option :value="p.value" :label="p.label"
v-for="p in settingsData.BlockDomainsOptions">
<span>[[ p.label ]]</span> <span>[[ p.label ]]</span>
</a-select-option> </a-select-option>
</a-select> </a-select>
</template> </template>
</a-setting-list-item> </a-setting-list-item>
<a-row :xs="24" :sm="24" :lg="12"> <a-row :xs="24" :sm="24" :lg="12">
<a-alert type="warning" :style="{ textAlign: 'center', marginTop: '20px' }"> <a-alert type="warning"
:style="{ textAlign: 'center', marginTop: '20px' }">
<template slot="message"> <template slot="message">
<a-icon type="exclamation-circle" theme="filled" :style="{ color: '#FFA031' }"></a-icon> <a-icon type="exclamation-circle" theme="filled"
<span>{{ i18n "pages.xray.directConnectionsConfigsDesc" }}</span> :style="{ color: '#FFA031' }"></a-icon>
<span>{{ i18n "pages.xray.directConnectionsConfigsDesc"
}}</span>
</template> </template>
</a-alert> </a-alert>
</a-row> </a-row>
<a-setting-list-item paddings="small"> <a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.xray.directips" }}</template> <template #title>{{ i18n "pages.xray.directips" }}</template>
<template #control> <template #control>
<a-select mode="tags" :style="{ width: '100%' }" v-model="directIPs" <a-select mode="tags" :style="{ width: '100%' }"
v-model="directIPs"
:dropdown-class-name="themeSwitcher.currentTheme"> :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option :value="p.value" :label="p.label" v-for="p in settingsData.IPsOptions"> <a-select-option :value="p.value" :label="p.label"
v-for="p in settingsData.IPsOptions">
<span>[[ p.label ]]</span> <span>[[ p.label ]]</span>
</a-select-option> </a-select-option>
</a-select> </a-select>
@ -202,18 +251,22 @@
<a-setting-list-item paddings="small"> <a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.xray.directdomains" }}</template> <template #title>{{ i18n "pages.xray.directdomains" }}</template>
<template #control> <template #control>
<a-select mode="tags" :style="{ width: '100%' }" v-model="directDomains" <a-select mode="tags" :style="{ width: '100%' }"
v-model="directDomains"
:dropdown-class-name="themeSwitcher.currentTheme"> :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option :value="p.value" :label="p.label" v-for="p in settingsData.DomainsOptions"> <a-select-option :value="p.value" :label="p.label"
v-for="p in settingsData.DomainsOptions">
<span>[[ p.label ]]</span> <span>[[ p.label ]]</span>
</a-select-option> </a-select-option>
</a-select> </a-select>
</template> </template>
</a-setting-list-item> </a-setting-list-item>
<a-row :xs="24" :sm="24" :lg="12"> <a-row :xs="24" :sm="24" :lg="12">
<a-alert type="warning" :style="{ textAlign: 'center', marginTop: '20px' }"> <a-alert type="warning"
:style="{ textAlign: 'center', marginTop: '20px' }">
<template slot="message"> <template slot="message">
<a-icon type="exclamation-circle" theme="filled" :style="{ color: '#FFA031' }"></a-icon> <a-icon type="exclamation-circle" theme="filled"
:style="{ color: '#FFA031' }"></a-icon>
<span>{{ i18n "pages.xray.ipv4RoutingDesc" }}</span> <span>{{ i18n "pages.xray.ipv4RoutingDesc" }}</span>
</template> </template>
</a-alert> </a-alert>
@ -221,18 +274,22 @@
<a-setting-list-item paddings="small"> <a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.xray.ipv4Routing" }}</template> <template #title>{{ i18n "pages.xray.ipv4Routing" }}</template>
<template #control> <template #control>
<a-select mode="tags" :style="{ width: '100%' }" v-model="ipv4Domains" <a-select mode="tags" :style="{ width: '100%' }"
v-model="ipv4Domains"
:dropdown-class-name="themeSwitcher.currentTheme"> :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option :value="p.value" :label="p.label" v-for="p in settingsData.ServicesOptions"> <a-select-option :value="p.value" :label="p.label"
v-for="p in settingsData.ServicesOptions">
<span>[[ p.label ]]</span> <span>[[ p.label ]]</span>
</a-select-option> </a-select-option>
</a-select> </a-select>
</template> </template>
</a-setting-list-item> </a-setting-list-item>
<a-row :xs="24" :sm="24" :lg="12"> <a-row :xs="24" :sm="24" :lg="12">
<a-alert type="warning" :style="{ textAlign: 'center', marginTop: '20px' }"> <a-alert type="warning"
:style="{ textAlign: 'center', marginTop: '20px' }">
<template slot="message"> <template slot="message">
<a-icon type="exclamation-circle" theme="filled" :style="{ color: '#FFA031' }"></a-icon> <a-icon type="exclamation-circle" theme="filled"
:style="{ color: '#FFA031' }"></a-icon>
{{ i18n "pages.xray.warpRoutingDesc" }} {{ i18n "pages.xray.warpRoutingDesc" }}
</template> </template>
</a-alert> </a-alert>
@ -241,20 +298,24 @@
<template #title>{{ i18n "pages.xray.warpRouting" }}</template> <template #title>{{ i18n "pages.xray.warpRouting" }}</template>
<template #control> <template #control>
<template v-if="WarpExist"> <template v-if="WarpExist">
<a-select mode="tags" :style="{ width: '100%' }" v-model="warpDomains" <a-select mode="tags" :style="{ width: '100%' }"
v-model="warpDomains"
:dropdown-class-name="themeSwitcher.currentTheme"> :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option :value="p.value" :label="p.label" v-for="p in settingsData.ServicesOptions"> <a-select-option :value="p.value" :label="p.label"
v-for="p in settingsData.ServicesOptions">
<span>[[ p.label ]]</span> <span>[[ p.label ]]</span>
</a-select-option> </a-select-option>
</a-select> </a-select>
</template> </template>
<template v-else> <template v-else>
<a-button type="primary" icon="cloud" @click="showWarp()">WARP</a-button> <a-button type="primary" icon="cloud"
@click="showWarp()">WARP</a-button>
</template> </template>
</template> </template>
</a-setting-list-item> </a-setting-list-item>
</a-collapse-panel> </a-collapse-panel>
<a-collapse-panel key="6" header='{{ i18n "pages.settings.resetDefaultConfig"}}'> <a-collapse-panel key="6"
header='{{ i18n "pages.settings.resetDefaultConfig"}}'>
<a-space direction="horizontal" :style="{ padding: '0 20px' }"> <a-space direction="horizontal" :style="{ padding: '0 20px' }">
<a-button type="danger" @click="resetXrayConfigToDefault"> <a-button type="danger" @click="resetXrayConfigToDefault">
<span>{{ i18n "pages.settings.resetDefaultConfig" }}</span> <span>{{ i18n "pages.settings.resetDefaultConfig" }}</span>

View file

@ -4,17 +4,22 @@
<a-col :xs="12" :sm="12" :lg="12"> <a-col :xs="12" :sm="12" :lg="12">
<a-space direction="horizontal" size="small"> <a-space direction="horizontal" size="small">
<a-button type="primary" icon="plus" @click="addOutbound"> <a-button type="primary" icon="plus" @click="addOutbound">
<span v-if="!isMobile">{{ i18n "pages.xray.outbound.addOutbound" }}</span> <span v-if="!isMobile">{{ i18n
"pages.xray.outbound.addOutbound" }}</span>
</a-button> </a-button>
<a-button type="primary" icon="cloud" @click="showWarp()">WARP</a-button> <a-button type="primary" icon="cloud"
@click="showWarp()">WARP</a-button>
</a-space> </a-space>
</a-col> </a-col>
<a-col :xs="12" :sm="12" :lg="12" :style="{ textAlign: 'right' }"> <a-col :xs="12" :sm="12" :lg="12" :style="{ textAlign: 'right' }">
<a-button-group> <a-button-group>
<a-button icon="sync" @click="refreshOutboundTraffic()" :loading="refreshing"></a-button> <a-button icon="sync" @click="refreshOutboundTraffic()"
<a-popconfirm placement="topRight" @confirm="resetOutboundTraffic(-1)" :loading="refreshing"></a-button>
<a-popconfirm placement="topRight"
@confirm="resetOutboundTraffic(-1)"
title='{{ i18n "pages.inbounds.resetTrafficContent"}}' title='{{ i18n "pages.inbounds.resetTrafficContent"}}'
:overlay-class-name="themeSwitcher.currentTheme" ok-text='{{ i18n "reset"}}' :overlay-class-name="themeSwitcher.currentTheme"
ok-text='{{ i18n "reset"}}'
cancel-text='{{ i18n "cancel"}}'> cancel-text='{{ i18n "cancel"}}'>
<a-icon slot="icon" type="question-circle-o" <a-icon slot="icon" type="question-circle-o"
:style="{ color: themeSwitcher.isDarkTheme ? '#008771' : '#008771' }"></a-icon> :style="{ color: themeSwitcher.isDarkTheme ? '#008771' : '#008771' }"></a-icon>
@ -23,8 +28,10 @@
</a-button-group> </a-button-group>
</a-col> </a-col>
</a-row> </a-row>
<a-table :columns="outboundColumns" bordered :row-key="r => r.key" :data-source="outboundData" <a-table :columns="outboundColumns" bordered :row-key="r => r.key"
:scroll="isMobile ? {} : { x: 800 }" :pagination="false" :indent-size="0" :data-source="outboundData"
:scroll="isMobile ? {} : { x: 800 }" :pagination="false"
:indent-size="0"
:locale='{ filterConfirm: `{{ i18n "confirm" }}`, filterReset: `{{ i18n "reset" }}` }'> :locale='{ filterConfirm: `{{ i18n "confirm" }}`, filterReset: `{{ i18n "reset" }}` }'>
<template slot="action" slot-scope="text, outbound, index"> <template slot="action" slot-scope="text, outbound, index">
<span>[[ index+1 ]]</span> <span>[[ index+1 ]]</span>
@ -32,7 +39,8 @@
<a-icon @click="e => e.preventDefault()" type="more" <a-icon @click="e => e.preventDefault()" type="more"
:style="{ fontSize: '16px', textDecoration: 'bold' }"></a-icon> :style="{ fontSize: '16px', textDecoration: 'bold' }"></a-icon>
<a-menu slot="overlay" :theme="themeSwitcher.currentTheme"> <a-menu slot="overlay" :theme="themeSwitcher.currentTheme">
<a-menu-item v-if="index>0" @click="setFirstOutbound(index)"> <a-menu-item v-if="index>0"
@click="setFirstOutbound(index)">
<a-icon type="vertical-align-top"></a-icon> <a-icon type="vertical-align-top"></a-icon>
<span>{{ i18n "pages.xray.rules.first"}}</span> <span>{{ i18n "pages.xray.rules.first"}}</span>
</a-menu-item> </a-menu-item>
@ -56,21 +64,64 @@
</a-dropdown> </a-dropdown>
</template> </template>
<template slot="address" slot-scope="text, outbound, index"> <template slot="address" slot-scope="text, outbound, index">
<p :style="{ margin: '0 5px' }" v-for="addr in findOutboundAddress(outbound)">[[ addr ]]</p> <p :style="{ margin: '0 5px' }"
v-for="addr in findOutboundAddress(outbound)">[[ addr ]]</p>
</template> </template>
<template slot="protocol" slot-scope="text, outbound, index"> <template slot="protocol" slot-scope="text, outbound, index">
<a-tag :style="{ margin: '0' }" color="purple">[[ outbound.protocol ]]</a-tag> <a-tag :style="{ margin: '0' }" color="purple">[[ outbound.protocol
]]</a-tag>
<template <template
v-if="[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(outbound.protocol)"> v-if="[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(outbound.protocol)">
<a-tag :style="{ margin: '0' }" color="blue">[[ outbound.streamSettings.network ]]</a-tag> <a-tag :style="{ margin: '0' }" color="blue">[[
<a-tag :style="{ margin: '0' }" v-if="outbound.streamSettings.security=='tls'" color="green">tls</a-tag> outbound.streamSettings.network ]]</a-tag>
<a-tag :style="{ margin: '0' }" v-if="outbound.streamSettings.security=='reality'" <a-tag :style="{ margin: '0' }"
v-if="outbound.streamSettings.security=='tls'"
color="green">tls</a-tag>
<a-tag :style="{ margin: '0' }"
v-if="outbound.streamSettings.security=='reality'"
color="green">reality</a-tag> color="green">reality</a-tag>
</template> </template>
</template> </template>
<template slot="traffic" slot-scope="text, outbound, index"> <template slot="traffic" slot-scope="text, outbound, index">
<a-tag color="green">[[ findOutboundTraffic(outbound) ]]</a-tag> <a-tag color="green">[[ findOutboundTraffic(outbound) ]]</a-tag>
</template> </template>
<template slot="test" slot-scope="text, outbound, index">
<a-tooltip>
<template slot="title">{{ i18n "pages.xray.outbound.test"
}}</template>
<a-button
type="primary"
shape="circle"
icon="thunderbolt"
:loading="outboundTestStates[index] && outboundTestStates[index].testing"
@click="testOutbound(index)"
:disabled="(outbound.protocol === 'blackhole' || outbound.tag === 'blocked') || (outboundTestStates[index] && outboundTestStates[index].testing)">
</a-button>
</a-tooltip>
</template>
<template slot="testResult" slot-scope="text, outbound, index">
<div
v-if="outboundTestStates[index] && outboundTestStates[index].result">
<a-tag v-if="outboundTestStates[index].result.success"
color="green">
[[ outboundTestStates[index].result.delay ]]ms
<span v-if="outboundTestStates[index].result.statusCode">
([[ outboundTestStates[index].result.statusCode
]])</span>
</a-tag>
<a-tooltip v-else
:title="outboundTestStates[index].result.error">
<a-tag color="red">
Failed
</a-tag>
</a-tooltip>
</div>
<span
v-else-if="outboundTestStates[index] && outboundTestStates[index].testing">
<a-icon type="loading" />
</span>
<span v-else>-</span>
</template>
</a-table> </a-table>
</a-space> </a-space>
{{end}} {{end}}

View file

@ -1,7 +1,10 @@
{{ template "page/head_start" .}} {{ template "page/head_start" .}}
<link rel="stylesheet" href="{{ .base_path }}assets/codemirror/codemirror.min.css?{{ .cur_ver }}"> <link rel="stylesheet"
<link rel="stylesheet" href="{{ .base_path }}assets/codemirror/fold/foldgutter.css"> href="{{ .base_path }}assets/codemirror/codemirror.min.css?{{ .cur_ver }}">
<link rel="stylesheet" href="{{ .base_path }}assets/codemirror/xq.min.css?{{ .cur_ver }}"> <link rel="stylesheet"
href="{{ .base_path }}assets/codemirror/fold/foldgutter.css">
<link rel="stylesheet"
href="{{ .base_path }}assets/codemirror/xq.min.css?{{ .cur_ver }}">
<link rel="stylesheet" href="{{ .base_path }}assets/codemirror/lint/lint.css"> <link rel="stylesheet" href="{{ .base_path }}assets/codemirror/lint/lint.css">
{{ template "page/head_end" .}} {{ template "page/head_end" .}}
@ -10,10 +13,13 @@
<a-sidebar></a-sidebar> <a-sidebar></a-sidebar>
<a-layout id="content-layout"> <a-layout id="content-layout">
<a-layout-content> <a-layout-content>
<a-spin :spinning="loadingStates.spinning" :delay="500" tip='{{ i18n "loading"}}'> <a-spin :spinning="loadingStates.spinning" :delay="500"
tip='{{ i18n "loading"}}'>
<transition name="list" appear> <transition name="list" appear>
<a-alert type="error" v-if="showAlert && loadingStates.fetched" :style="{ marginBottom: '10px' }" <a-alert type="error" v-if="showAlert && loadingStates.fetched"
message='{{ i18n "secAlertTitle" }}' color="red" description='{{ i18n "secAlertSsl" }}' show-icon closable> :style="{ marginBottom: '10px' }"
message='{{ i18n "secAlertTitle" }}' color="red"
description='{{ i18n "secAlertSsl" }}' show-icon closable>
</a-alert> </a-alert>
</transition> </transition>
<transition name="list" appear> <transition name="list" appear>
@ -26,19 +32,25 @@
<a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-else> <a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-else>
<a-col> <a-col>
<a-card hoverable> <a-card hoverable>
<a-row :style="{ display: 'flex', flexWrap: 'wrap', alignItems: 'center' }"> <a-row
:style="{ display: 'flex', flexWrap: 'wrap', alignItems: 'center' }">
<a-col :xs="24" :sm="10" :style="{ padding: '4px' }"> <a-col :xs="24" :sm="10" :style="{ padding: '4px' }">
<a-space direction="horizontal"> <a-space direction="horizontal">
<a-button type="primary" :disabled="saveBtnDisable" @click="updateXraySetting"> <a-button type="primary" :disabled="saveBtnDisable"
@click="updateXraySetting">
{{ i18n "pages.xray.save" }} {{ i18n "pages.xray.save" }}
</a-button> </a-button>
<a-button type="danger" :disabled="!saveBtnDisable" @click="restartXray"> <a-button type="danger" :disabled="!saveBtnDisable"
@click="restartXray">
{{ i18n "pages.xray.restart" }} {{ i18n "pages.xray.restart" }}
</a-button> </a-button>
<a-popover v-if="restartResult" :overlay-class-name="themeSwitcher.currentTheme"> <a-popover v-if="restartResult"
<span slot="title">{{ i18n "pages.index.xrayErrorPopoverTitle" }}</span> :overlay-class-name="themeSwitcher.currentTheme">
<span slot="title">{{ i18n
"pages.index.xrayErrorPopoverTitle" }}</span>
<template slot="content"> <template slot="content">
<span :style="{ maxWidth: '400px' }" v-for="line in restartResult.split('\n')">[[ line <span :style="{ maxWidth: '400px' }"
v-for="line in restartResult.split('\n')">[[ line
]]</span> ]]</span>
</template> </template>
<a-icon type="question-circle"></a-icon> <a-icon type="question-circle"></a-icon>
@ -48,10 +60,13 @@
<a-col :xs="24" :sm="14"> <a-col :xs="24" :sm="14">
<template> <template>
<div> <div>
<a-back-top :target="() => document.getElementById('content-layout')" <a-back-top
:target="() => document.getElementById('content-layout')"
visibility-height="200"></a-back-top> visibility-height="200"></a-back-top>
<a-alert type="warning" :style="{ float: 'right', width: 'fit-content' }" <a-alert type="warning"
message='{{ i18n "pages.settings.infoDesc" }}' show-icon> :style="{ float: 'right', width: 'fit-content' }"
message='{{ i18n "pages.settings.infoDesc" }}'
show-icon>
</a-alert> </a-alert>
</div> </div>
</template> </template>
@ -60,7 +75,8 @@
</a-card> </a-card>
</a-col> </a-col>
<a-col> <a-col>
<a-tabs default-active-key="tpl-basic" @change="(activeKey) => { this.changePage(activeKey); }" <a-tabs default-active-key="tpl-basic"
@change="(activeKey) => { this.changePage(activeKey); }"
:class="themeSwitcher.currentTheme"> :class="themeSwitcher.currentTheme">
<a-tab-pane key="tpl-basic" :style="{ paddingTop: '20px' }"> <a-tab-pane key="tpl-basic" :style="{ paddingTop: '20px' }">
<template #tab> <template #tab>
@ -83,21 +99,24 @@
</template> </template>
{{ template "settings/xray/outbounds" . }} {{ template "settings/xray/outbounds" . }}
</a-tab-pane> </a-tab-pane>
<a-tab-pane key="tpl-reverse" :style="{ paddingTop: '20px' }" force-render="true"> <a-tab-pane key="tpl-reverse" :style="{ paddingTop: '20px' }"
force-render="true">
<template #tab> <template #tab>
<a-icon type="import"></a-icon> <a-icon type="import"></a-icon>
<span>{{ i18n "pages.xray.outbound.reverse"}}</span> <span>{{ i18n "pages.xray.outbound.reverse"}}</span>
</template> </template>
{{ template "settings/xray/reverse" . }} {{ template "settings/xray/reverse" . }}
</a-tab-pane> </a-tab-pane>
<a-tab-pane key="tpl-balancer" :style="{ paddingTop: '20px' }" force-render="true"> <a-tab-pane key="tpl-balancer" :style="{ paddingTop: '20px' }"
force-render="true">
<template #tab> <template #tab>
<a-icon type="cluster"></a-icon> <a-icon type="cluster"></a-icon>
<span>{{ i18n "pages.xray.Balancers"}}</span> <span>{{ i18n "pages.xray.Balancers"}}</span>
</template> </template>
{{ template "settings/xray/balancers" . }} {{ template "settings/xray/balancers" . }}
</a-tab-pane> </a-tab-pane>
<a-tab-pane key="tpl-dns" :style="{ paddingTop: '20px' }" force-render="true"> <a-tab-pane key="tpl-dns" :style="{ paddingTop: '20px' }"
force-render="true">
<template #tab> <template #tab>
<a-icon type="database"></a-icon> <a-icon type="database"></a-icon>
<span>DNS</span> <span>DNS</span>
@ -120,14 +139,18 @@
</a-layout> </a-layout>
</a-layout> </a-layout>
{{template "page/body_scripts" .}} {{template "page/body_scripts" .}}
<script src="{{ .base_path }}assets/js/model/outbound.js?{{ .cur_ver }}"></script> <script
<script src="{{ .base_path }}assets/codemirror/codemirror.min.js?{{ .cur_ver }}"></script> src="{{ .base_path }}assets/js/model/outbound.js?{{ .cur_ver }}"></script>
<script
src="{{ .base_path }}assets/codemirror/codemirror.min.js?{{ .cur_ver }}"></script>
<script src="{{ .base_path }}assets/codemirror/javascript.js"></script> <script src="{{ .base_path }}assets/codemirror/javascript.js"></script>
<script src="{{ .base_path }}assets/codemirror/jshint.js"></script> <script src="{{ .base_path }}assets/codemirror/jshint.js"></script>
<script src="{{ .base_path }}assets/codemirror/jsonlint.js"></script> <script src="{{ .base_path }}assets/codemirror/jsonlint.js"></script>
<script src="{{ .base_path }}assets/codemirror/lint/lint.js"></script> <script src="{{ .base_path }}assets/codemirror/lint/lint.js"></script>
<script src="{{ .base_path }}assets/codemirror/lint/javascript-lint.js"></script> <script
<script src="{{ .base_path }}assets/codemirror/hint/javascript-hint.js"></script> src="{{ .base_path }}assets/codemirror/lint/javascript-lint.js"></script>
<script
src="{{ .base_path }}assets/codemirror/hint/javascript-hint.js"></script>
<script src="{{ .base_path }}assets/codemirror/fold/foldcode.js"></script> <script src="{{ .base_path }}assets/codemirror/fold/foldcode.js"></script>
<script src="{{ .base_path }}assets/codemirror/fold/foldgutter.js"></script> <script src="{{ .base_path }}assets/codemirror/fold/foldgutter.js"></script>
<script src="{{ .base_path }}assets/codemirror/fold/brace-fold.js"></script> <script src="{{ .base_path }}assets/codemirror/fold/brace-fold.js"></script>
@ -181,11 +204,13 @@
]; ];
const outboundColumns = [ const outboundColumns = [
{ title: "#", align: 'center', width: 20, scopedSlots: { customRender: 'action' } }, { title: "#", align: 'center', width: 60, scopedSlots: { customRender: 'action' } },
{ title: '{{ i18n "pages.xray.outbound.tag"}}', dataIndex: 'tag', align: 'center', width: 50 }, { title: '{{ i18n "pages.xray.outbound.tag"}}', dataIndex: 'tag', align: 'center', width: 50 },
{ title: '{{ i18n "protocol"}}', align: 'center', width: 50, scopedSlots: { customRender: 'protocol' } }, { title: '{{ i18n "protocol"}}', align: 'center', width: 50, scopedSlots: { customRender: 'protocol' } },
{ title: '{{ i18n "pages.xray.outbound.address"}}', align: 'center', width: 50, scopedSlots: { customRender: 'address' } }, { title: '{{ i18n "pages.xray.outbound.address"}}', align: 'center', width: 50, scopedSlots: { customRender: 'address' } },
{ title: '{{ i18n "pages.inbounds.traffic" }}', align: 'center', width: 50, scopedSlots: { customRender: 'traffic' } }, { title: '{{ i18n "pages.inbounds.traffic" }}', align: 'center', width: 50, scopedSlots: { customRender: 'traffic' } },
{ title: '{{ i18n "pages.xray.outbound.testResult" }}', align: 'center', width: 120, scopedSlots: { customRender: 'testResult' } },
{ title: '{{ i18n "pages.xray.outbound.test" }}', align: 'center', width: 60, scopedSlots: { customRender: 'test' } },
]; ];
const reverseColumns = [ const reverseColumns = [
@ -228,8 +253,11 @@
}, },
oldXraySetting: '', oldXraySetting: '',
xraySetting: '', xraySetting: '',
outboundTestUrl: 'https://www.google.com/generate_204',
oldOutboundTestUrl: 'https://www.google.com/generate_204',
inboundTags: [], inboundTags: [],
outboundsTraffic: [], outboundsTraffic: [],
outboundTestStates: {}, // Track testing state and results for each outbound
saveBtnDisable: true, saveBtnDisable: true,
refreshing: false, refreshing: false,
restartResult: '', restartResult: '',
@ -337,14 +365,14 @@
}, },
defaultObservatory: { defaultObservatory: {
subjectSelector: [], subjectSelector: [],
probeURL: "http://www.google.com/gen_204", probeURL: "https://www.google.com/generate_204",
probeInterval: "10m", probeInterval: "10m",
enableConcurrency: true enableConcurrency: true
}, },
defaultBurstObservatory: { defaultBurstObservatory: {
subjectSelector: [], subjectSelector: [],
pingConfig: { pingConfig: {
destination: "http://www.google.com/gen_204", destination: "https://www.google.com/generate_204",
interval: "30m", interval: "30m",
connectivity: "http://connectivitycheck.platform.hicloud.com/generate_204", connectivity: "http://connectivitycheck.platform.hicloud.com/generate_204",
timeout: "10s", timeout: "10s",
@ -375,12 +403,17 @@
this.oldXraySetting = xs; this.oldXraySetting = xs;
this.xraySetting = xs; this.xraySetting = xs;
this.inboundTags = result.inboundTags; this.inboundTags = result.inboundTags;
this.outboundTestUrl = result.outboundTestUrl || 'https://www.google.com/generate_204';
this.oldOutboundTestUrl = this.outboundTestUrl;
this.saveBtnDisable = true; this.saveBtnDisable = true;
} }
}, },
async updateXraySetting() { async updateXraySetting() {
this.loading(true); this.loading(true);
const msg = await HttpUtil.post("/panel/xray/update", { xraySetting: this.xraySetting }); const msg = await HttpUtil.post("/panel/xray/update", {
xraySetting: this.xraySetting,
outboundTestUrl: this.outboundTestUrl || 'https://www.google.com/generate_204'
});
this.loading(false); this.loading(false);
if (msg.success) { if (msg.success) {
await this.getXraySetting(); await this.getXraySetting();
@ -595,6 +628,71 @@
outbounds.splice(0, 0, outbounds.splice(index, 1)[0]); outbounds.splice(0, 0, outbounds.splice(index, 1)[0]);
this.outboundSettings = JSON.stringify(outbounds); this.outboundSettings = JSON.stringify(outbounds);
}, },
async testOutbound(index) {
const outbound = this.templateSettings.outbounds[index];
if (!outbound) {
Vue.prototype.$message.error('{{ i18n "pages.xray.outbound.testError" }}');
return;
}
if (outbound.protocol === 'blackhole' || outbound.tag === 'blocked') {
Vue.prototype.$message.warning('{{ i18n "pages.xray.outbound.testError" }}: blocked/blackhole outbound');
return;
}
// Initialize test state for this outbound if not exists
if (!this.outboundTestStates[index]) {
this.$set(this.outboundTestStates, index, {
testing: false,
result: null
});
}
// Set testing state
this.$set(this.outboundTestStates[index], 'testing', true);
this.$set(this.outboundTestStates[index], 'result', null);
try {
const outboundJSON = JSON.stringify(outbound);
const allOutboundsJSON = JSON.stringify(this.templateSettings.outbounds || []);
const msg = await HttpUtil.post("/panel/xray/testOutbound", {
outbound: outboundJSON,
allOutbounds: allOutboundsJSON
});
// Update test state
this.$set(this.outboundTestStates[index], 'testing', false);
if (msg.success && msg.obj) {
const result = msg.obj;
this.$set(this.outboundTestStates[index], 'result', result);
if (result.success) {
Vue.prototype.$message.success(
`{{ i18n "pages.xray.outbound.testSuccess" }}: ${result.delay}ms (${result.statusCode})`
);
} else {
Vue.prototype.$message.error(
`{{ i18n "pages.xray.outbound.testFailed" }}: ${result.error || 'Unknown error'}`
);
}
} else {
this.$set(this.outboundTestStates[index], 'result', {
success: false,
error: msg.msg || '{{ i18n "pages.xray.outbound.testError" }}'
});
Vue.prototype.$message.error(msg.msg || '{{ i18n "pages.xray.outbound.testError" }}');
}
} catch (error) {
this.$set(this.outboundTestStates[index], 'testing', false);
this.$set(this.outboundTestStates[index], 'result', {
success: false,
error: error.message || '{{ i18n "pages.xray.outbound.testError" }}'
});
Vue.prototype.$message.error('{{ i18n "pages.xray.outbound.testError" }}: ' + error.message);
}
},
addReverse() { addReverse() {
reverseModal.show({ reverseModal.show({
title: '{{ i18n "pages.xray.outbound.addReverse"}}', title: '{{ i18n "pages.xray.outbound.addReverse"}}',
@ -981,7 +1079,7 @@
while (true) { while (true) {
await PromiseUtil.sleep(800); await PromiseUtil.sleep(800);
this.saveBtnDisable = this.oldXraySetting === this.xraySetting; this.saveBtnDisable = this.oldXraySetting === this.xraySetting && this.oldOutboundTestUrl === this.outboundTestUrl;
} }
}, },
computed: { computed: {

View file

@ -10,6 +10,7 @@ import (
"regexp" "regexp"
"runtime" "runtime"
"sort" "sort"
"strconv"
"time" "time"
"github.com/mhsanaei/3x-ui/v2/database" "github.com/mhsanaei/3x-ui/v2/database"
@ -18,6 +19,12 @@ import (
"github.com/mhsanaei/3x-ui/v2/xray" "github.com/mhsanaei/3x-ui/v2/xray"
) )
// IPWithTimestamp tracks an IP address with its last seen timestamp
type IPWithTimestamp struct {
IP string `json:"ip"`
Timestamp int64 `json:"timestamp"`
}
// CheckClientIpJob monitors client IP addresses from access logs and manages IP blocking based on configured limits. // CheckClientIpJob monitors client IP addresses from access logs and manages IP blocking based on configured limits.
type CheckClientIpJob struct { type CheckClientIpJob struct {
lastClear int64 lastClear int64
@ -119,12 +126,14 @@ func (j *CheckClientIpJob) processLogFile() bool {
ipRegex := regexp.MustCompile(`from (?:tcp:|udp:)?\[?([0-9a-fA-F\.:]+)\]?:\d+ accepted`) ipRegex := regexp.MustCompile(`from (?:tcp:|udp:)?\[?([0-9a-fA-F\.:]+)\]?:\d+ accepted`)
emailRegex := regexp.MustCompile(`email: (.+)$`) emailRegex := regexp.MustCompile(`email: (.+)$`)
timestampRegex := regexp.MustCompile(`^(\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2})`)
accessLogPath, _ := xray.GetAccessLogPath() accessLogPath, _ := xray.GetAccessLogPath()
file, _ := os.Open(accessLogPath) file, _ := os.Open(accessLogPath)
defer file.Close() defer file.Close()
inboundClientIps := make(map[string]map[string]struct{}, 100) // Track IPs with their last seen timestamp
inboundClientIps := make(map[string]map[string]int64, 100)
scanner := bufio.NewScanner(file) scanner := bufio.NewScanner(file)
for scanner.Scan() { for scanner.Scan() {
@ -147,28 +156,45 @@ func (j *CheckClientIpJob) processLogFile() bool {
} }
email := emailMatches[1] email := emailMatches[1]
if _, exists := inboundClientIps[email]; !exists { // Extract timestamp from log line
inboundClientIps[email] = make(map[string]struct{}) 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 shouldCleanLog := false
for email, uniqueIps := range inboundClientIps { for email, ipTimestamps := range inboundClientIps {
ips := make([]string, 0, len(uniqueIps)) // Convert to IPWithTimestamp slice
for ip := range uniqueIps { ipsWithTime := make([]IPWithTimestamp, 0, len(ipTimestamps))
ips = append(ips, ip) for ip, timestamp := range ipTimestamps {
ipsWithTime = append(ipsWithTime, IPWithTimestamp{IP: ip, Timestamp: timestamp})
} }
sort.Strings(ips)
clientIpsRecord, err := j.getInboundClientIps(email) clientIpsRecord, err := j.getInboundClientIps(email)
if err != nil { if err != nil {
j.addInboundClientIps(email, ips) j.addInboundClientIps(email, ipsWithTime)
continue continue
} }
shouldCleanLog = j.updateInboundClientIps(clientIpsRecord, email, ips) || shouldCleanLog shouldCleanLog = j.updateInboundClientIps(clientIpsRecord, email, ipsWithTime) || shouldCleanLog
} }
return shouldCleanLog return shouldCleanLog
@ -213,9 +239,9 @@ func (j *CheckClientIpJob) getInboundClientIps(clientEmail string) (*model.Inbou
return InboundClientIps, nil return InboundClientIps, nil
} }
func (j *CheckClientIpJob) addInboundClientIps(clientEmail string, ips []string) error { func (j *CheckClientIpJob) addInboundClientIps(clientEmail string, ipsWithTime []IPWithTimestamp) error {
inboundClientIps := &model.InboundClientIps{} inboundClientIps := &model.InboundClientIps{}
jsonIps, err := json.Marshal(ips) jsonIps, err := json.Marshal(ipsWithTime)
j.checkError(err) j.checkError(err)
inboundClientIps.ClientEmail = clientEmail inboundClientIps.ClientEmail = clientEmail
@ -239,16 +265,8 @@ func (j *CheckClientIpJob) addInboundClientIps(clientEmail string, ips []string)
return nil return nil
} }
func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.InboundClientIps, clientEmail string, ips []string) bool { func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.InboundClientIps, clientEmail string, newIpsWithTime []IPWithTimestamp) bool {
jsonIps, err := json.Marshal(ips) // Get the inbound configuration
if err != nil {
logger.Error("failed to marshal IPs to JSON:", err)
return false
}
inboundClientIps.ClientEmail = clientEmail
inboundClientIps.Ips = string(jsonIps)
inbound, err := j.getInboundByEmail(clientEmail) inbound, err := j.getInboundByEmail(clientEmail)
if err != nil { if err != nil {
logger.Errorf("failed to fetch inbound settings for email %s: %s", clientEmail, err) logger.Errorf("failed to fetch inbound settings for email %s: %s", clientEmail, err)
@ -263,9 +281,57 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
settings := map[string][]model.Client{} settings := map[string][]model.Client{}
json.Unmarshal([]byte(inbound.Settings), &settings) json.Unmarshal([]byte(inbound.Settings), &settings)
clients := settings["clients"] clients := settings["clients"]
// Find the client's IP limit
var limitIp int
var clientFound bool
for _, client := range clients {
if client.Email == clientEmail {
limitIp = client.LimitIP
clientFound = true
break
}
}
if !clientFound || limitIp <= 0 || !inbound.Enable {
// No limit or inbound disabled, just update and return
jsonIps, _ := json.Marshal(newIpsWithTime)
inboundClientIps.Ips = string(jsonIps)
db := database.GetDB()
db.Save(inboundClientIps)
return false
}
// Parse old IPs from database
var oldIpsWithTime []IPWithTimestamp
if inboundClientIps.Ips != "" {
json.Unmarshal([]byte(inboundClientIps.Ips), &oldIpsWithTime)
}
// Merge old and new IPs, 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 shouldCleanLog := false
j.disAllowedIps = []string{} j.disAllowedIps = []string{}
// Open log file
logIpFile, err := os.OpenFile(xray.GetIPLimitLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) logIpFile, err := os.OpenFile(xray.GetIPLimitLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil { if err != nil {
logger.Errorf("failed to open IP limit log file: %s", err) logger.Errorf("failed to open IP limit log file: %s", err)
@ -275,27 +341,33 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
log.SetOutput(logIpFile) log.SetOutput(logIpFile)
log.SetFlags(log.LstdFlags) log.SetFlags(log.LstdFlags)
for _, client := range clients { // Check if we exceed the limit
if client.Email == clientEmail { if len(allIps) > limitIp {
limitIp := client.LimitIP shouldCleanLog = true
if limitIp > 0 && inbound.Enable { // Keep only the newest IPs (up to limitIp)
shouldCleanLog = true keptIps := allIps[:limitIp]
disconnectedIps := allIps[limitIp:]
if limitIp < len(ips) { // Log the disconnected IPs (old ones)
j.disAllowedIps = append(j.disAllowedIps, ips[limitIp:]...) for _, ipTime := range disconnectedIps {
for i := limitIp; i < len(ips); i++ { j.disAllowedIps = append(j.disAllowedIps, ipTime.IP)
log.Printf("[LIMIT_IP] Email = %s || SRC = %s", clientEmail, ips[i]) 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 { // Update database with only the newest IPs
logger.Debug("disAllowedIps:", j.disAllowedIps) 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() db := database.GetDB()
@ -305,9 +377,68 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
return false 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 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) { func (j *CheckClientIpJob) getInboundByEmail(clientEmail string) (*model.Inbound, error) {
db := database.GetDB() db := database.GetDB()
inbound := &model.Inbound{} inbound := &model.Inbound{}

View file

@ -71,7 +71,7 @@ func (j *XrayTrafficJob) Run() {
} }
// Broadcast traffic update via WebSocket with accumulated values from database // Broadcast traffic update via WebSocket with accumulated values from database
trafficUpdate := map[string]interface{}{ trafficUpdate := map[string]any{
"traffics": traffics, "traffics": traffics,
"clientTraffics": clientTraffics, "clientTraffics": clientTraffics,
"onlineClients": onlineClients, "onlineClients": onlineClients,

View file

@ -2141,6 +2141,43 @@ func (s *InboundService) GetInboundClientIps(clientEmail string) (string, error)
if err != nil { if err != nil {
return "", err return "", err
} }
if InboundClientIps.Ips == "" {
return "", nil
}
// Try to parse as new format (with timestamps)
type IPWithTimestamp struct {
IP string `json:"ip"`
Timestamp int64 `json:"timestamp"`
}
var ipsWithTime []IPWithTimestamp
err = json.Unmarshal([]byte(InboundClientIps.Ips), &ipsWithTime)
// If successfully parsed as new format, return with timestamps
if err == nil && len(ipsWithTime) > 0 {
return InboundClientIps.Ips, nil
}
// Otherwise, assume it's old format (simple string array)
// Try to parse as simple array and convert to new format
var oldIps []string
err = json.Unmarshal([]byte(InboundClientIps.Ips), &oldIps)
if err == nil && len(oldIps) > 0 {
// Convert old format to new format with current timestamp
newIpsWithTime := make([]IPWithTimestamp, len(oldIps))
for i, ip := range oldIps {
newIpsWithTime[i] = IPWithTimestamp{
IP: ip,
Timestamp: time.Now().Unix(),
}
}
result, _ := json.Marshal(newIpsWithTime)
return string(result), nil
}
// Return as-is if parsing fails
return InboundClientIps.Ips, nil return InboundClientIps.Ips, nil
} }

View file

@ -1,9 +1,22 @@
package service package service
import ( import (
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"sync"
"time"
"github.com/mhsanaei/3x-ui/v2/config"
"github.com/mhsanaei/3x-ui/v2/database" "github.com/mhsanaei/3x-ui/v2/database"
"github.com/mhsanaei/3x-ui/v2/database/model" "github.com/mhsanaei/3x-ui/v2/database/model"
"github.com/mhsanaei/3x-ui/v2/logger" "github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/util/common"
"github.com/mhsanaei/3x-ui/v2/util/json_util"
"github.com/mhsanaei/3x-ui/v2/xray" "github.com/mhsanaei/3x-ui/v2/xray"
"gorm.io/gorm" "gorm.io/gorm"
@ -13,6 +26,9 @@ import (
// It handles outbound traffic monitoring and statistics. // It handles outbound traffic monitoring and statistics.
type OutboundService struct{} type OutboundService struct{}
// testSemaphore limits concurrent outbound tests to prevent resource exhaustion.
var testSemaphore sync.Mutex
func (s *OutboundService) AddTraffic(traffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (error, bool) { func (s *OutboundService) AddTraffic(traffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (error, bool) {
var err error var err error
db := database.GetDB() db := database.GetDB()
@ -100,3 +116,307 @@ func (s *OutboundService) ResetOutboundTraffic(tag string) error {
return nil return nil
} }
// TestOutboundResult represents the result of testing an outbound
type TestOutboundResult struct {
Success bool `json:"success"`
Delay int64 `json:"delay"` // Delay in milliseconds
Error string `json:"error,omitempty"`
StatusCode int `json:"statusCode,omitempty"`
}
// TestOutbound tests an outbound by creating a temporary xray instance and measuring response time.
// allOutboundsJSON must be a JSON array of all outbounds; they are copied into the test config unchanged.
// Only the test inbound and a route rule (to the tested outbound tag) are added.
func (s *OutboundService) TestOutbound(outboundJSON string, testURL string, allOutboundsJSON string) (*TestOutboundResult, error) {
if testURL == "" {
testURL = "https://www.google.com/generate_204"
}
// Limit to one concurrent test at a time
if !testSemaphore.TryLock() {
return &TestOutboundResult{
Success: false,
Error: "Another outbound test is already running, please wait",
}, nil
}
defer testSemaphore.Unlock()
// Parse the outbound being tested to get its tag
var testOutbound map[string]any
if err := json.Unmarshal([]byte(outboundJSON), &testOutbound); err != nil {
return &TestOutboundResult{
Success: false,
Error: fmt.Sprintf("Invalid outbound JSON: %v", err),
}, nil
}
outboundTag, _ := testOutbound["tag"].(string)
if outboundTag == "" {
return &TestOutboundResult{
Success: false,
Error: "Outbound has no tag",
}, nil
}
if protocol, _ := testOutbound["protocol"].(string); protocol == "blackhole" || outboundTag == "blocked" {
return &TestOutboundResult{
Success: false,
Error: "Blocked/blackhole outbound cannot be tested",
}, nil
}
// Use all outbounds when provided; otherwise fall back to single outbound
var allOutbounds []any
if allOutboundsJSON != "" {
if err := json.Unmarshal([]byte(allOutboundsJSON), &allOutbounds); err != nil {
return &TestOutboundResult{
Success: false,
Error: fmt.Sprintf("Invalid allOutbounds JSON: %v", err),
}, nil
}
}
if len(allOutbounds) == 0 {
allOutbounds = []any{testOutbound}
}
// Find an available port for test inbound
testPort, err := findAvailablePort()
if err != nil {
return &TestOutboundResult{
Success: false,
Error: fmt.Sprintf("Failed to find available port: %v", err),
}, nil
}
// Copy all outbounds as-is, add only test inbound and route rule
testConfig := s.createTestConfig(outboundTag, allOutbounds, testPort)
// Use a temporary config file so the main config.json is never overwritten
testConfigPath, err := createTestConfigPath()
if err != nil {
return &TestOutboundResult{
Success: false,
Error: fmt.Sprintf("Failed to create test config path: %v", err),
}, nil
}
defer os.Remove(testConfigPath) // ensure temp file is removed even if process is not stopped
// Create temporary xray process with its own config file
testProcess := xray.NewTestProcess(testConfig, testConfigPath)
defer func() {
if testProcess.IsRunning() {
testProcess.Stop()
}
}()
// Start the test process
if err := testProcess.Start(); err != nil {
return &TestOutboundResult{
Success: false,
Error: fmt.Sprintf("Failed to start test xray instance: %v", err),
}, nil
}
// Wait for xray to start listening on the test port
if err := waitForPort(testPort, 3*time.Second); err != nil {
if !testProcess.IsRunning() {
result := testProcess.GetResult()
return &TestOutboundResult{
Success: false,
Error: fmt.Sprintf("Xray process exited: %s", result),
}, nil
}
return &TestOutboundResult{
Success: false,
Error: fmt.Sprintf("Xray failed to start listening: %v", err),
}, nil
}
// Check if process is still running
if !testProcess.IsRunning() {
result := testProcess.GetResult()
return &TestOutboundResult{
Success: false,
Error: fmt.Sprintf("Xray process exited: %s", result),
}, nil
}
// Test the connection through proxy
delay, statusCode, err := s.testConnection(testPort, testURL)
if err != nil {
return &TestOutboundResult{
Success: false,
Error: err.Error(),
}, nil
}
return &TestOutboundResult{
Success: true,
Delay: delay,
StatusCode: statusCode,
}, nil
}
// createTestConfig creates a test config by copying all outbounds unchanged and adding
// only the test inbound (SOCKS) and a route rule that sends traffic to the given outbound tag.
func (s *OutboundService) createTestConfig(outboundTag string, allOutbounds []any, testPort int) *xray.Config {
// Test inbound (SOCKS proxy) - only addition to inbounds
testInbound := xray.InboundConfig{
Tag: "test-inbound",
Listen: json_util.RawMessage(`"127.0.0.1"`),
Port: testPort,
Protocol: "socks",
Settings: json_util.RawMessage(`{"auth":"noauth","udp":true}`),
}
// Outbounds: copy all, but set noKernelTun=true for WireGuard outbounds
processedOutbounds := make([]any, len(allOutbounds))
for i, ob := range allOutbounds {
outbound, ok := ob.(map[string]any)
if !ok {
processedOutbounds[i] = ob
continue
}
if protocol, ok := outbound["protocol"].(string); ok && protocol == "wireguard" {
// Set noKernelTun to true for WireGuard outbounds
if settings, ok := outbound["settings"].(map[string]any); ok {
settings["noKernelTun"] = true
} else {
// Create settings if it doesn't exist
outbound["settings"] = map[string]any{
"noKernelTun": true,
}
}
}
processedOutbounds[i] = outbound
}
outboundsJSON, _ := json.Marshal(processedOutbounds)
// Create routing rule to route all traffic through test outbound
routingRules := []map[string]any{
{
"type": "field",
"outboundTag": outboundTag,
"network": "tcp,udp",
},
}
routingJSON, _ := json.Marshal(map[string]any{
"domainStrategy": "AsIs",
"rules": routingRules,
})
// Disable logging for test process to avoid creating orphaned log files
logConfig := map[string]any{
"loglevel": "warning",
"access": "none",
"error": "none",
"dnsLog": false,
}
logJSON, _ := json.Marshal(logConfig)
// Create minimal config
cfg := &xray.Config{
LogConfig: json_util.RawMessage(logJSON),
InboundConfigs: []xray.InboundConfig{
testInbound,
},
OutboundConfigs: json_util.RawMessage(string(outboundsJSON)),
RouterConfig: json_util.RawMessage(string(routingJSON)),
Policy: json_util.RawMessage(`{}`),
Stats: json_util.RawMessage(`{}`),
}
return cfg
}
// testConnection tests the connection through the proxy and measures delay.
// It performs a warmup request first to establish the SOCKS connection and populate DNS caches,
// then measures the second request for a more accurate latency reading.
func (s *OutboundService) testConnection(proxyPort int, testURL string) (int64, int, error) {
// Create SOCKS5 proxy URL
proxyURL := fmt.Sprintf("socks5://127.0.0.1:%d", proxyPort)
// Parse proxy URL
proxyURLParsed, err := url.Parse(proxyURL)
if err != nil {
return 0, 0, common.NewErrorf("Invalid proxy URL: %v", err)
}
// Create HTTP client with proxy and keep-alive for connection reuse
client := &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
Proxy: http.ProxyURL(proxyURLParsed),
DialContext: (&net.Dialer{
Timeout: 5 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 1,
IdleConnTimeout: 10 * time.Second,
DisableCompression: true,
},
}
// Warmup request: establishes SOCKS/TLS connection, DNS, and TCP to the target.
// This mirrors real-world usage where connections are reused.
warmupResp, err := client.Get(testURL)
if err != nil {
return 0, 0, common.NewErrorf("Request failed: %v", err)
}
io.Copy(io.Discard, warmupResp.Body)
warmupResp.Body.Close()
// Measure the actual request on the warm connection
startTime := time.Now()
resp, err := client.Get(testURL)
delay := time.Since(startTime).Milliseconds()
if err != nil {
return 0, 0, common.NewErrorf("Request failed: %v", err)
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
return delay, resp.StatusCode, nil
}
// waitForPort polls until the given TCP port is accepting connections or the timeout expires.
func waitForPort(port int, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 100*time.Millisecond)
if err == nil {
conn.Close()
return nil
}
time.Sleep(50 * time.Millisecond)
}
return fmt.Errorf("port %d not ready after %v", port, timeout)
}
// findAvailablePort finds an available port for testing
func findAvailablePort() (int, error) {
listener, err := net.Listen("tcp", ":0")
if err != nil {
return 0, err
}
defer listener.Close()
addr := listener.Addr().(*net.TCPAddr)
return addr.Port, nil
}
// createTestConfigPath returns a unique path for a temporary xray config file in the bin folder.
// The temp file is created and closed so the path is reserved; Start() will overwrite it.
func createTestConfigPath() (string, error) {
tmpFile, err := os.CreateTemp(config.GetBinFolderPath(), "xray_test_*.json")
if err != nil {
return "", err
}
path := tmpFile.Name()
if err := tmpFile.Close(); err != nil {
os.Remove(path)
return "", err
}
return path, nil
}

View file

@ -567,7 +567,7 @@ func (s *ServerService) GetXrayVersions() ([]string, error) {
continue continue
} }
if major > 26 || (major == 26 && minor > 1) || (major == 26 && minor == 1 && patch >= 31) { if major > 26 || (major == 26 && minor > 2) || (major == 26 && minor == 2 && patch >= 6) {
versions = append(versions, release.TagName) versions = append(versions, release.TagName)
} }
} }
@ -1056,44 +1056,79 @@ func (s *ServerService) IsValidGeofileName(filename string) bool {
} }
func (s *ServerService) UpdateGeofile(fileName string) error { func (s *ServerService) UpdateGeofile(fileName string) error {
files := []struct { type geofileEntry struct {
URL string URL string
FileName string FileName string
}{ }
{"https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat", "geoip.dat"}, geofileAllowlist := map[string]geofileEntry{
{"https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat", "geosite.dat"}, "geoip.dat": {"https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat", "geoip.dat"},
{"https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat", "geoip_IR.dat"}, "geosite.dat": {"https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat", "geosite.dat"},
{"https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat", "geosite_IR.dat"}, "geoip_IR.dat": {"https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat", "geoip_IR.dat"},
{"https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat", "geoip_RU.dat"}, "geosite_IR.dat": {"https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat", "geosite_IR.dat"},
{"https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat", "geosite_RU.dat"}, "geoip_RU.dat": {"https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat", "geoip_RU.dat"},
"geosite_RU.dat": {"https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat", "geosite_RU.dat"},
} }
// Strict allowlist check to avoid writing uncontrolled files // Strict allowlist check to avoid writing uncontrolled files
if fileName != "" { if fileName != "" {
// Use the centralized validation function if _, ok := geofileAllowlist[fileName]; !ok {
if !s.IsValidGeofileName(fileName) { return common.NewErrorf("Invalid geofile name: %q not in allowlist", fileName)
return common.NewErrorf("Invalid geofile name: contains unsafe path characters: %s", fileName)
}
// Ensure the filename matches exactly one from our allowlist
isAllowed := false
for _, file := range files {
if fileName == file.FileName {
isAllowed = true
break
}
}
if !isAllowed {
return common.NewErrorf("Invalid geofile name: %s not in allowlist", fileName)
} }
} }
downloadFile := func(url, destPath string) error { downloadFile := func(url, destPath string) error {
resp, err := http.Get(url) var req *http.Request
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return common.NewErrorf("Failed to create HTTP request for %s: %v", url, err)
}
var localFileModTime time.Time
if fileInfo, err := os.Stat(destPath); err == nil {
localFileModTime = fileInfo.ModTime()
if !localFileModTime.IsZero() {
req.Header.Set("If-Modified-Since", localFileModTime.UTC().Format(http.TimeFormat))
}
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil { if err != nil {
return common.NewErrorf("Failed to download Geofile from %s: %v", url, err) return common.NewErrorf("Failed to download Geofile from %s: %v", url, err)
} }
defer resp.Body.Close() defer resp.Body.Close()
// Parse Last-Modified header from server
var serverModTime time.Time
serverModTimeStr := resp.Header.Get("Last-Modified")
if serverModTimeStr != "" {
parsedTime, err := time.Parse(http.TimeFormat, serverModTimeStr)
if err != nil {
logger.Warningf("Failed to parse Last-Modified header for %s: %v", url, err)
} else {
serverModTime = parsedTime
}
}
// Function to update local file's modification time
updateFileModTime := func() {
if !serverModTime.IsZero() {
if err := os.Chtimes(destPath, serverModTime, serverModTime); err != nil {
logger.Warningf("Failed to update modification time for %s: %v", destPath, err)
}
}
}
// Handle 304 Not Modified
if resp.StatusCode == http.StatusNotModified {
updateFileModTime()
return nil
}
if resp.StatusCode != http.StatusOK {
return common.NewErrorf("Failed to download Geofile from %s: received status code %d", url, resp.StatusCode)
}
file, err := os.Create(destPath) file, err := os.Create(destPath)
if err != nil { if err != nil {
return common.NewErrorf("Failed to create Geofile %s: %v", destPath, err) return common.NewErrorf("Failed to create Geofile %s: %v", destPath, err)
@ -1105,39 +1140,25 @@ func (s *ServerService) UpdateGeofile(fileName string) error {
return common.NewErrorf("Failed to save Geofile %s: %v", destPath, err) return common.NewErrorf("Failed to save Geofile %s: %v", destPath, err)
} }
updateFileModTime()
return nil return nil
} }
var errorMessages []string var errorMessages []string
if fileName == "" { if fileName == "" {
for _, file := range files { // Download all geofiles
// Sanitize the filename from our allowlist as an extra precaution for _, entry := range geofileAllowlist {
destPath := filepath.Join(config.GetBinFolderPath(), filepath.Base(file.FileName)) destPath := filepath.Join(config.GetBinFolderPath(), entry.FileName)
if err := downloadFile(entry.URL, destPath); err != nil {
if err := downloadFile(file.URL, destPath); err != nil { errorMessages = append(errorMessages, fmt.Sprintf("Error downloading Geofile '%s': %v", entry.FileName, err))
errorMessages = append(errorMessages, fmt.Sprintf("Error downloading Geofile '%s': %v", file.FileName, err))
} }
} }
} else { } else {
// Use filepath.Base to ensure we only get the filename component, no path traversal entry := geofileAllowlist[fileName]
safeName := filepath.Base(fileName) destPath := filepath.Join(config.GetBinFolderPath(), entry.FileName)
destPath := filepath.Join(config.GetBinFolderPath(), safeName) if err := downloadFile(entry.URL, destPath); err != nil {
errorMessages = append(errorMessages, fmt.Sprintf("Error downloading Geofile '%s': %v", entry.FileName, err))
var fileURL string
for _, file := range files {
if file.FileName == fileName {
fileURL = file.URL
break
}
}
if fileURL == "" {
errorMessages = append(errorMessages, fmt.Sprintf("File '%s' not found in the list of Geofiles", fileName))
} else {
if err := downloadFile(fileURL, destPath); err != nil {
errorMessages = append(errorMessages, fmt.Sprintf("Error downloading Geofile '%s': %v", fileName, err))
}
} }
} }

View file

@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"net"
"reflect" "reflect"
"strconv" "strconv"
"strings" "strings"
@ -78,6 +79,8 @@ var defaultValueMap = map[string]string{
"warp": "", "warp": "",
"externalTrafficInformEnable": "false", "externalTrafficInformEnable": "false",
"externalTrafficInformURI": "", "externalTrafficInformURI": "",
"xrayOutboundTestUrl": "https://www.google.com/generate_204",
// LDAP defaults // LDAP defaults
"ldapEnable": "false", "ldapEnable": "false",
"ldapHost": "", "ldapHost": "",
@ -271,6 +274,14 @@ func (s *SettingService) GetXrayConfigTemplate() (string, error) {
return s.getString("xrayTemplateConfig") return s.getString("xrayTemplateConfig")
} }
func (s *SettingService) GetXrayOutboundTestUrl() (string, error) {
return s.getString("xrayOutboundTestUrl")
}
func (s *SettingService) SetXrayOutboundTestUrl(url string) error {
return s.setString("xrayOutboundTestUrl", url)
}
func (s *SettingService) GetListen() (string, error) { func (s *SettingService) GetListen() (string, error) {
return s.getString("webListen") return s.getString("webListen")
} }
@ -707,6 +718,28 @@ func (s *SettingService) GetDefaultXrayConfig() (any, error) {
return jsonData, nil return jsonData, nil
} }
func extractHostname(host string) string {
h, _, err := net.SplitHostPort(host)
// Err is not nil means host does not contain port
if err != nil {
h = host
}
ip := net.ParseIP(h)
// If it's not an IP, return as is
if ip == nil {
return h
}
// If it's an IPv4, return as is
if ip.To4() != nil {
return h
}
// IPv6 needs bracketing
return "[" + h + "]"
}
func (s *SettingService) GetDefaultSettings(host string) (any, error) { func (s *SettingService) GetDefaultSettings(host string) (any, error) {
type settingFunc func() (any, error) type settingFunc func() (any, error)
settings := map[string]settingFunc{ settings := map[string]settingFunc{
@ -757,7 +790,7 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
subTLS = true subTLS = true
} }
if subDomain == "" { if subDomain == "" {
subDomain = strings.Split(host, ":")[0] subDomain = extractHostname(host)
} }
if subTLS { if subTLS {
subURI = "https://" subURI = "https://"

View file

@ -5,6 +5,7 @@ import (
"crypto/rand" "crypto/rand"
"embed" "embed"
"encoding/base64" "encoding/base64"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@ -2322,9 +2323,9 @@ func (t *Tgbot) buildSubscriptionURLs(email string) (string, string, error) {
// If pre-configured URIs are available, use them directly // If pre-configured URIs are available, use them directly
if subURI != "" { if subURI != "" {
if !strings.HasSuffix(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 { } else {
subURL = fmt.Sprintf("%s://%s%s%s", scheme, host, subPath, client.SubID) 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, "/") { if !strings.HasSuffix(subJsonURI, "/") {
subJsonURI = subJsonURI + "/" subJsonURI = subJsonURI + "/"
} }
subJsonURL = fmt.Sprintf("%s%s", subJsonURI, client.SubID) subJsonURL = fmt.Sprintf("%s%s", subJsonURI, client.SubID)
} else { } else {
subJsonURL = fmt.Sprintf("%s://%s%s%s", scheme, host, subJsonPath, client.SubID) 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") ips = t.I18nBot("tgbot.noIpRecord")
} }
formattedIps := ips
if err == nil && len(ips) > 0 {
type ipWithTimestamp struct {
IP string `json:"ip"`
Timestamp int64 `json:"timestamp"`
}
var ipsWithTime []ipWithTimestamp
if json.Unmarshal([]byte(ips), &ipsWithTime) == nil && len(ipsWithTime) > 0 {
lines := make([]string, 0, len(ipsWithTime))
for _, item := range ipsWithTime {
if item.IP == "" {
continue
}
if item.Timestamp > 0 {
ts := time.Unix(item.Timestamp, 0).Format("2006-01-02 15:04:05")
lines = append(lines, fmt.Sprintf("%s (%s)", item.IP, ts))
continue
}
lines = append(lines, item.IP)
}
if len(lines) > 0 {
formattedIps = strings.Join(lines, "\n")
}
} else {
var oldIps []string
if json.Unmarshal([]byte(ips), &oldIps) == nil && len(oldIps) > 0 {
formattedIps = strings.Join(oldIps, "\n")
}
}
}
output := "" output := ""
output += t.I18nBot("tgbot.messages.email", "Email=="+email) output += t.I18nBot("tgbot.messages.email", "Email=="+email)
output += t.I18nBot("tgbot.messages.ips", "IPs=="+ips) output += t.I18nBot("tgbot.messages.ips", "IPs=="+formattedIps)
output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05")) output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
inlineKeyboard := tu.InlineKeyboard( inlineKeyboard := tu.InlineKeyboard(

View file

@ -460,6 +460,8 @@
"FreedomStrategyDesc" = "اختار استراتيجية المخرجات للشبكة في بروتوكول الحرية." "FreedomStrategyDesc" = "اختار استراتيجية المخرجات للشبكة في بروتوكول الحرية."
"RoutingStrategy" = "استراتيجية التوجيه العامة" "RoutingStrategy" = "استراتيجية التوجيه العامة"
"RoutingStrategyDesc" = "حدد استراتيجية التوجيه الإجمالية لحل كل الطلبات." "RoutingStrategyDesc" = "حدد استراتيجية التوجيه الإجمالية لحل كل الطلبات."
"outboundTestUrl" = "رابط اختبار المخرج"
"outboundTestUrlDesc" = "الرابط المستخدم عند اختبار اتصال المخرج"
"Torrent" = "حظر بروتوكول التورنت" "Torrent" = "حظر بروتوكول التورنت"
"Inbounds" = "الإدخالات" "Inbounds" = "الإدخالات"
"InboundsDesc" = "قبول العملاء المعينين." "InboundsDesc" = "قبول العملاء المعينين."

View file

@ -460,6 +460,8 @@
"FreedomStrategyDesc" = "Set the output strategy for the network in the Freedom Protocol." "FreedomStrategyDesc" = "Set the output strategy for the network in the Freedom Protocol."
"RoutingStrategy" = "Overall Routing Strategy" "RoutingStrategy" = "Overall Routing Strategy"
"RoutingStrategyDesc" = "Set the overall traffic routing strategy for resolving all requests." "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" "Torrent" = "Block BitTorrent Protocol"
"Inbounds" = "Inbounds" "Inbounds" = "Inbounds"
"InboundsDesc" = "Accepting the specific clients." "InboundsDesc" = "Accepting the specific clients."
@ -523,6 +525,12 @@
"accountInfo" = "Account Information" "accountInfo" = "Account Information"
"outboundStatus" = "Outbound Status" "outboundStatus" = "Outbound Status"
"sendThrough" = "Send Through" "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] [pages.xray.balancer]
"addBalancer" = "Add Balancer" "addBalancer" = "Add Balancer"

View file

@ -9,7 +9,7 @@
"copy" = "Copiar" "copy" = "Copiar"
"copied" = "Copiado" "copied" = "Copiado"
"download" = "Descargar" "download" = "Descargar"
"remark" = "Nota" "remark" = "Notas"
"enable" = "Habilitar" "enable" = "Habilitar"
"protocol" = "Protocolo" "protocol" = "Protocolo"
"search" = "Buscar" "search" = "Buscar"
@ -28,14 +28,14 @@
"edit" = "Editar" "edit" = "Editar"
"delete" = "Eliminar" "delete" = "Eliminar"
"reset" = "Restablecer" "reset" = "Restablecer"
"noData" = "Sin datos." "noData" = "Sin datos"
"copySuccess" = "Copiado exitosamente" "copySuccess" = "Copiado exitosamente"
"sure" = "Seguro" "sure" = "Seguro"
"encryption" = "Encriptación" "encryption" = "Encriptación"
"useIPv4ForHost" = "Usar IPv4 para el host" "useIPv4ForHost" = "Usar IPv4 para el host"
"transmission" = "Transmisión" "transmission" = "Transmisión"
"host" = "Anfitrión" "host" = "Host"
"path" = "Ruta" "path" = "Path"
"camouflage" = "Camuflaje" "camouflage" = "Camuflaje"
"status" = "Estado" "status" = "Estado"
"enabled" = "Habilitado" "enabled" = "Habilitado"
@ -114,7 +114,7 @@
"cpu" = "CPU" "cpu" = "CPU"
"logicalProcessors" = "Procesadores lógicos" "logicalProcessors" = "Procesadores lógicos"
"frequency" = "Frecuencia" "frequency" = "Frecuencia"
"swap" = "Intercambio" "swap" = "Memoria Virtual"
"storage" = "Almacenamiento" "storage" = "Almacenamiento"
"memory" = "RAM" "memory" = "RAM"
"threads" = "Hilos" "threads" = "Hilos"
@ -167,7 +167,7 @@
[pages.inbounds] [pages.inbounds]
"allTimeTraffic" = "Tráfico Total" "allTimeTraffic" = "Tráfico Total"
"allTimeTrafficUsage" = "Uso total de todos los tiempos" "allTimeTrafficUsage" = "Uso de datos histórico"
"title" = "Entradas" "title" = "Entradas"
"totalDownUp" = "Subidas/Descargas Totales" "totalDownUp" = "Subidas/Descargas Totales"
"totalUsage" = "Uso Total" "totalUsage" = "Uso Total"
@ -201,7 +201,7 @@
"destinationPort" = "Puerto de Destino" "destinationPort" = "Puerto de Destino"
"targetAddress" = "Dirección de Destino" "targetAddress" = "Dirección de Destino"
"monitorDesc" = "Dejar en blanco por defecto" "monitorDesc" = "Dejar en blanco por defecto"
"meansNoLimit" = "= illimitata. (unidad: GB)" "meansNoLimit" = " = illimitata. (unidad: GB)"
"totalFlow" = "Flujo Total" "totalFlow" = "Flujo Total"
"leaveBlankToNeverExpire" = "Dejar en Blanco para Nunca Expirar" "leaveBlankToNeverExpire" = "Dejar en Blanco para Nunca Expirar"
"noRecommendKeepDefault" = "No hay requisitos especiales para mantener la configuración predeterminada" "noRecommendKeepDefault" = "No hay requisitos especiales para mantener la configuración predeterminada"
@ -283,7 +283,7 @@
"inboundClientAddSuccess" = "Cliente(s) de entrada añadido(s)" "inboundClientAddSuccess" = "Cliente(s) de entrada añadido(s)"
"inboundClientDeleteSuccess" = "Cliente de entrada eliminado" "inboundClientDeleteSuccess" = "Cliente de entrada eliminado"
"inboundClientUpdateSuccess" = "Cliente de entrada actualizado" "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" "resetAllClientTrafficSuccess" = "Todo el tráfico del cliente ha sido reiniciado"
"resetAllTrafficSuccess" = "Todo el tráfico ha sido reiniciado" "resetAllTrafficSuccess" = "Todo el tráfico ha sido reiniciado"
"resetInboundClientTrafficSuccess" = "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." "subEnableDesc" = "Función de suscripción con configuración separada."
"subJsonEnable" = "Habilitar/Deshabilitar el endpoint de suscripción JSON de forma independiente." "subJsonEnable" = "Habilitar/Deshabilitar el endpoint de suscripción JSON de forma independiente."
"subTitle" = "Título de la Suscripción" "subTitle" = "Título de la Suscripción"
"subTitleDesc" = "Título mostrado en el cliente de VPN" "subTitleDesc" = "Título mostrado en el cliente VPN"
"subSupportUrl" = "URL de soporte" "subSupportUrl" = "URL de soporte"
"subSupportUrlDesc" = "Enlace de soporte técnico mostrado en el cliente VPN" "subSupportUrlDesc" = "Enlace de soporte técnico mostrado en el cliente VPN"
"subProfileUrl" = "URL del perfil" "subProfileUrl" = "URL del perfil"
@ -411,8 +411,8 @@
"fragment" = "Fragmentación" "fragment" = "Fragmentación"
"fragmentDesc" = "Habilitar la fragmentación para el paquete de saludo de TLS" "fragmentDesc" = "Habilitar la fragmentación para el paquete de saludo de TLS"
"fragmentSett" = "Configuración de Fragmentación" "fragmentSett" = "Configuración de Fragmentación"
"noisesDesc" = "Activar Noises." "noisesDesc" = "Activar Sonidos"
"noisesSett" = "Configuración de Noises" "noisesSett" = "Configuración de Sonidos"
"mux" = "Mux" "mux" = "Mux"
"muxDesc" = "Transmite múltiples flujos de datos independientes dentro de un flujo de datos establecido." "muxDesc" = "Transmite múltiples flujos de datos independientes dentro de un flujo de datos establecido."
"muxSett" = "Configuración Mux" "muxSett" = "Configuración Mux"
@ -436,8 +436,8 @@
"stopSuccess" = "Xray se ha detenido correctamente" "stopSuccess" = "Xray se ha detenido correctamente"
"restartError" = "Ocurrió un error al reiniciar Xray." "restartError" = "Ocurrió un error al reiniciar Xray."
"stopError" = "Ocurrió un error al detener Xray." "stopError" = "Ocurrió un error al detener Xray."
"basicTemplate" = "Plantilla Básica" "basicTemplate" = "Perfil Básico"
"advancedTemplate" = "Plantilla Avanzada" "advancedTemplate" = "Perfil Avanzado"
"generalConfigs" = "Configuraciones Generales" "generalConfigs" = "Configuraciones Generales"
"generalConfigsDesc" = "Estas opciones proporcionarán ajustes generales." "generalConfigsDesc" = "Estas opciones proporcionarán ajustes generales."
"logConfigs" = "Registro" "logConfigs" = "Registro"
@ -460,6 +460,8 @@
"FreedomStrategyDesc" = "Establece la estrategia de salida de la red en el Protocolo Freedom." "FreedomStrategyDesc" = "Establece la estrategia de salida de la red en el Protocolo Freedom."
"RoutingStrategy" = "Configurar Estrategia de Enrutamiento de Dominios" "RoutingStrategy" = "Configurar Estrategia de Enrutamiento de Dominios"
"RoutingStrategyDesc" = "Establece la estrategia general de enrutamiento para la resolución de DNS." "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" "Torrent" = "Prohibir Uso de BitTorrent"
"Inbounds" = "Entrante" "Inbounds" = "Entrante"
"InboundsDesc" = "Cambia la plantilla de configuración para aceptar clientes específicos." "InboundsDesc" = "Cambia la plantilla de configuración para aceptar clientes específicos."
@ -610,8 +612,8 @@
[tgbot] [tgbot]
"keyboardClosed" = "❌ Teclado cerrado!" "keyboardClosed" = "❌ Teclado cerrado!"
"noResult" = "❗ ¡No hay resultados!" "noResult" = "❗ ¡Sin resultados!"
"noQuery" = "❌ ¡Consulta no encontrada! ¡Por favor, use el comando de nuevo!" "noQuery" = "❌ ¡Consulta no encontrada! ¡Por favor, use el comando nuevamente!"
"wentWrong" = "❌ ¡Algo salió mal!" "wentWrong" = "❌ ¡Algo salió mal!"
"noIpRecord" = "❗ ¡No hay registro de IP!" "noIpRecord" = "❗ ¡No hay registro de IP!"
"noInbounds" = "❗ ¡No se encontraron entradas!" "noInbounds" = "❗ ¡No se encontraron entradas!"

View file

@ -460,6 +460,8 @@
"FreedomStrategyDesc" = "تعیین می‌کند Freedom استراتژی خروجی شبکه را برای پروتکل" "FreedomStrategyDesc" = "تعیین می‌کند Freedom استراتژی خروجی شبکه را برای پروتکل"
"RoutingStrategy" = "استراتژی کلی مسیریابی" "RoutingStrategy" = "استراتژی کلی مسیریابی"
"RoutingStrategyDesc" = "استراتژی کلی مسیریابی برای حل تمام درخواست‌ها را تعیین می‌کند" "RoutingStrategyDesc" = "استراتژی کلی مسیریابی برای حل تمام درخواست‌ها را تعیین می‌کند"
"outboundTestUrl" = "آدرس تست خروجی"
"outboundTestUrlDesc" = "آدرسی که برای تست اتصال خروجی استفاده می‌شود."
"Torrent" = "مسدودسازی پروتکل بیت‌تورنت" "Torrent" = "مسدودسازی پروتکل بیت‌تورنت"
"Inbounds" = "ورودی‌ها" "Inbounds" = "ورودی‌ها"
"InboundsDesc" = "پذیرش کلاینت خاص" "InboundsDesc" = "پذیرش کلاینت خاص"

View file

@ -460,6 +460,8 @@
"FreedomStrategyDesc" = "Atur strategi output untuk jaringan dalam Protokol Freedom." "FreedomStrategyDesc" = "Atur strategi output untuk jaringan dalam Protokol Freedom."
"RoutingStrategy" = "Strategi Pengalihan Keseluruhan" "RoutingStrategy" = "Strategi Pengalihan Keseluruhan"
"RoutingStrategyDesc" = "Atur strategi pengalihan lalu lintas keseluruhan untuk menyelesaikan semua permintaan." "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" "Torrent" = "Blokir Protokol BitTorrent"
"Inbounds" = "Masuk" "Inbounds" = "Masuk"
"InboundsDesc" = "Menerima klien tertentu." "InboundsDesc" = "Menerima klien tertentu."

View file

@ -460,6 +460,8 @@
"FreedomStrategyDesc" = "Freedomプロトコル内のネットワークの出力戦略を設定する" "FreedomStrategyDesc" = "Freedomプロトコル内のネットワークの出力戦略を設定する"
"RoutingStrategy" = "ルーティングドメイン戦略設定" "RoutingStrategy" = "ルーティングドメイン戦略設定"
"RoutingStrategyDesc" = "DNS解決の全体的なルーティング戦略を設定する" "RoutingStrategyDesc" = "DNS解決の全体的なルーティング戦略を設定する"
"outboundTestUrl" = "アウトバウンドテスト URL"
"outboundTestUrlDesc" = "アウトバウンド接続テストに使用する URL。既定値"
"Torrent" = "BitTorrent プロトコルをブロック" "Torrent" = "BitTorrent プロトコルをブロック"
"Inbounds" = "インバウンドルール" "Inbounds" = "インバウンドルール"
"InboundsDesc" = "特定のクライアントからのトラフィックを受け入れる" "InboundsDesc" = "特定のクライアントからのトラフィックを受け入れる"

View file

@ -460,6 +460,8 @@
"FreedomStrategyDesc" = "Definir a estratégia de saída para a rede no Protocolo Freedom." "FreedomStrategyDesc" = "Definir a estratégia de saída para a rede no Protocolo Freedom."
"RoutingStrategy" = "Estratégia Geral de Roteamento" "RoutingStrategy" = "Estratégia Geral de Roteamento"
"RoutingStrategyDesc" = "Definir a estratégia geral de roteamento de tráfego para resolver todas as solicitações." "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" "Torrent" = "Bloquear Protocolo BitTorrent"
"Inbounds" = "Inbounds" "Inbounds" = "Inbounds"
"InboundsDesc" = "Aceitar clientes específicos." "InboundsDesc" = "Aceitar clientes específicos."

View file

@ -460,6 +460,8 @@
"FreedomStrategyDesc" = "Установка стратегии вывода сети в протоколе Freedom" "FreedomStrategyDesc" = "Установка стратегии вывода сети в протоколе Freedom"
"RoutingStrategy" = "Настройка маршрутизации доменов" "RoutingStrategy" = "Настройка маршрутизации доменов"
"RoutingStrategyDesc" = "Установка общей стратегии маршрутизации разрешения DNS" "RoutingStrategyDesc" = "Установка общей стратегии маршрутизации разрешения DNS"
"outboundTestUrl" = "URL для теста исходящего"
"outboundTestUrlDesc" = "URL для проверки подключения исходящего"
"Torrent" = "Заблокировать BitTorrent" "Torrent" = "Заблокировать BitTorrent"
"Inbounds" = "Входящие подключения" "Inbounds" = "Входящие подключения"
"InboundsDesc" = "Изменение шаблона конфигурации для подключения определенных клиентов" "InboundsDesc" = "Изменение шаблона конфигурации для подключения определенных клиентов"

View file

@ -460,6 +460,8 @@
"FreedomStrategyDesc" = "Freedom Protokolünde ağın çıkış stratejisini ayarlayın." "FreedomStrategyDesc" = "Freedom Protokolünde ağın çıkış stratejisini ayarlayın."
"RoutingStrategy" = "Genel Yönlendirme Stratejisi" "RoutingStrategy" = "Genel Yönlendirme Stratejisi"
"RoutingStrategyDesc" = "Tüm istekleri çözmek için genel trafik yönlendirme stratejisini ayarlayın." "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" "Torrent" = "BitTorrent Protokolünü Engelle"
"Inbounds" = "Gelenler" "Inbounds" = "Gelenler"
"InboundsDesc" = "Belirli müşterileri kabul eder." "InboundsDesc" = "Belirli müşterileri kabul eder."

View file

@ -460,6 +460,8 @@
"FreedomStrategyDesc" = "Установити стратегію виведення для мережі в протоколі свободи." "FreedomStrategyDesc" = "Установити стратегію виведення для мережі в протоколі свободи."
"RoutingStrategy" = "Загальна стратегія маршрутизації" "RoutingStrategy" = "Загальна стратегія маршрутизації"
"RoutingStrategyDesc" = "Установити загальну стратегію маршрутизації трафіку для вирішення всіх запитів." "RoutingStrategyDesc" = "Установити загальну стратегію маршрутизації трафіку для вирішення всіх запитів."
"outboundTestUrl" = "URL тесту outbound"
"outboundTestUrlDesc" = "URL для перевірки з'єднання outbound"
"Torrent" = "Блокувати протокол BitTorrent" "Torrent" = "Блокувати протокол BitTorrent"
"Inbounds" = "Вхідні" "Inbounds" = "Вхідні"
"InboundsDesc" = "Прийняття певних клієнтів." "InboundsDesc" = "Прийняття певних клієнтів."

View file

@ -460,6 +460,8 @@
"FreedomStrategyDesc" = "Đặt chiến lược đầu ra của mạng trong Giao thức Freedom." "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" "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." "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" "Torrent" = "Cấu hình sử dụng BitTorrent"
"Inbounds" = "Đầu vào" "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ể." "InboundsDesc" = "Thay đổi mẫu cấu hình để chấp nhận các máy khách cụ thể."

View file

@ -460,6 +460,8 @@
"FreedomStrategyDesc" = "设置 Freedom 协议中网络的输出策略" "FreedomStrategyDesc" = "设置 Freedom 协议中网络的输出策略"
"RoutingStrategy" = "配置路由域策略" "RoutingStrategy" = "配置路由域策略"
"RoutingStrategyDesc" = "设置 DNS 解析的整体路由策略" "RoutingStrategyDesc" = "设置 DNS 解析的整体路由策略"
"outboundTestUrl" = "出站测试 URL"
"outboundTestUrlDesc" = "测试出站连接时使用的 URL"
"Torrent" = "屏蔽 BitTorrent 协议" "Torrent" = "屏蔽 BitTorrent 协议"
"Inbounds" = "入站规则" "Inbounds" = "入站规则"
"InboundsDesc" = "接受来自特定客户端的流量" "InboundsDesc" = "接受来自特定客户端的流量"

View file

@ -460,6 +460,8 @@
"FreedomStrategyDesc" = "設定 Freedom 協議中網路的輸出策略" "FreedomStrategyDesc" = "設定 Freedom 協議中網路的輸出策略"
"RoutingStrategy" = "配置路由域策略" "RoutingStrategy" = "配置路由域策略"
"RoutingStrategyDesc" = "設定 DNS 解析的整體路由策略" "RoutingStrategyDesc" = "設定 DNS 解析的整體路由策略"
"outboundTestUrl" = "出站測試 URL"
"outboundTestUrlDesc" = "測試出站連線時使用的 URL"
"Torrent" = "遮蔽 BitTorrent 協議" "Torrent" = "遮蔽 BitTorrent 協議"
"Inbounds" = "入站規則" "Inbounds" = "入站規則"
"InboundsDesc" = "接受來自特定客戶端的流量" "InboundsDesc" = "接受來自特定客戶端的流量"

View file

@ -49,7 +49,7 @@ func BroadcastInbounds(inbounds any) {
} }
// BroadcastOutbounds broadcasts outbounds list update to all connected clients // BroadcastOutbounds broadcasts outbounds list update to all connected clients
func BroadcastOutbounds(outbounds interface{}) { func BroadcastOutbounds(outbounds any) {
hub := GetHub() hub := GetHub()
if hub != nil { if hub != nil {
hub.Broadcast(MessageTypeOutbounds, outbounds) hub.Broadcast(MessageTypeOutbounds, outbounds)

View file

@ -2062,11 +2062,15 @@ SSH_port_forwarding() {
) )
local server_ip="" local server_ip=""
for ip_address in "${URL_lists[@]}"; do for ip_address in "${URL_lists[@]}"; do
server_ip=$(curl -s --max-time 3 "${ip_address}" 2>/dev/null | tr -d '[:space:]') local response=$(curl -s -w "\n%{http_code}" --max-time 3 "${ip_address}" 2>/dev/null)
if [[ -n "${server_ip}" ]]; then local http_code=$(echo "$response" | tail -n1)
local ip_result=$(echo "$response" | head -n-1 | tr -d '[:space:]')
if [[ "${http_code}" == "200" && -n "${ip_result}" ]]; then
server_ip="${ip_result}"
break break
fi fi
done done
local existing_webBasePath=$(${xui_folder}/x-ui setting -show true | grep -Eo 'webBasePath: .+' | awk '{print $2}') 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_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}') local existing_listenIP=$(${xui_folder}/x-ui setting -getListen true | grep -Eo 'listenIP: .+' | awk '{print $2}')

View file

@ -110,6 +110,15 @@ func NewProcess(xrayConfig *Config) *Process {
return p 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 { type process struct {
cmd *exec.Cmd cmd *exec.Cmd
@ -118,10 +127,11 @@ type process struct {
onlineClients []string onlineClients []string
config *Config config *Config
logWriter *LogWriter configPath string // if set, use this path instead of GetConfigPath() and remove on Stop
exitErr error logWriter *LogWriter
startTime time.Time exitErr error
startTime time.Time
} }
// newProcess creates a new internal process struct for Xray. // 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. // IsRunning returns true if the Xray process is currently running.
func (p *process) IsRunning() bool { func (p *process) IsRunning() bool {
if p.cmd == nil || p.cmd.Process == nil { if p.cmd == nil || p.cmd.Process == nil {
@ -238,6 +255,9 @@ func (p *process) Start() (err error) {
} }
configPath := GetConfigPath() configPath := GetConfigPath()
if p.configPath != "" {
configPath = p.configPath
}
err = os.WriteFile(configPath, data, fs.ModePerm) err = os.WriteFile(configPath, data, fs.ModePerm)
if err != nil { if err != nil {
return common.NewErrorf("Failed to write configuration file: %v", err) 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") 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" { if runtime.GOOS == "windows" {
return p.cmd.Process.Kill() return p.cmd.Process.Kill()
} else { } else {