Compare commits

...

53 commits
v2.8.7 ... 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
MHSanaei
f3d47ebb3f
Refactor TLS peer cert verification settings
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
Removed verifyPeerCertByNames and pinnedPeerCertSha256 from inbound TLS settings and UI. Added verifyPeerCertByName and pinnedPeerCertSha256 to outbound TLS settings and updated the outbound form to support these fields. This change streamlines and clarifies certificate verification configuration between inbound and outbound settings.
2026-02-01 14:03:46 +01:00
MHSanaei
06c49b92f8
v2.8.9
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
2026-02-01 04:05:02 +01:00
MHSanaei
e35213bc73
Update Xray-core to v26.1.31 and related dependencies
Bump Xray-core version to v26.1.31 in build scripts and server logic. Update Go dependencies including gopsutil, bytedance/sonic, circl, miekg/dns, go-proxyproto, sagernet/sing, and others to their latest versions. Adjust version check in GetXrayVersions to require at least v26.1.31.
2026-02-01 03:30:09 +01:00
MHSanaei
aa6a886977
Add UDP hop interval min/max support for Hysteria
Replaces single UDP hop interval with separate min and max values in Hysteria stream settings. Updates model, JSON serialization, URL param parsing, and form fields for backward compatibility and enhanced configuration flexibility.
2026-02-01 03:20:29 +01:00
MHSanaei
9d603c5ad2
Add pinnedPeerCertSha256 support to TLS settings
Introduces the pinnedPeerCertSha256 field to TlsStreamSettings in the JS model and adds a corresponding input in the TLS settings form. This allows users to specify SHA256 fingerprints for peer certificate pinning, enhancing security configuration options.
2026-02-01 03:12:54 +01:00
MHSanaei
a973fa6d68
XHTTP transport: New options for bypassing CDN's detection
https://github.com/XTLS/Xray-core/pull/5414
2026-02-01 02:58:18 +01:00
MHSanaei
3af6497577
inbound : finalmask 2026-02-01 02:36:57 +01:00
MHSanaei
c59f54bb0e
outbound: finalmask 2026-02-01 01:56:23 +01:00
lillinlin
6b3da4fe5e
Update reality_targets.js (#3724)
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
2026-01-31 23:50:29 +01:00
Farhad H. P. Shirvan
ea0da32e81
fix: rename verifyPeerCertInNames to verifyPeerCertByName to be compatible with xray-core v26.1.31 (#3723) 2026-01-31 19:50:08 +01:00
Sam Mosleh
d5ea8d0f38
Fix default CA by enforcing it everywhere (#3719)
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
2026-01-30 16:35:24 +01:00
Danil S.
fd5f591737
feat: more subscription information fields (#3701)
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
* feat: more subscription information fields

* fix: incorrect translation

* feat: implement field for Happ custom routing rules
2026-01-26 23:06:01 +01:00
Sam Mosleh
8a4c9a98cb
Fix modifying default CA (#3708) 2026-01-26 23:05:15 +01:00
sviatoslav-gusev
70b365171f
feat: add option to use existing custom SSL certificates (#3688) 2026-01-21 16:47:36 +01:00
mr-shura
328ba3b45e
fix Telegram bot ignores reverse proxy setting #3673 (#3684)
Refactor URL construction to use pre-configured URIs if available, otherwise fallback to default scheme and host.
2026-01-19 12:33:17 +01:00
Nebulosa
5370b6943a
Add hysteria2 protocol in hint text (#3686) 2026-01-19 12:31:49 +01:00
MHSanaei
d8c783a296
v2.8.8
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-01-18 18:01:58 +01:00
MHSanaei
809f69729a
Update minimum Xray version requirement
Raised the minimum required Xray version from 25.9.11 to 26.1.18 in GetXrayVersions. This ensures only newer versions are considered valid.
2026-01-18 17:50:00 +01:00
MHSanaei
93b7ce199f
Add UDP mask support for Hysteria outbound
Introduces a 'congestion' option to Hysteria stream settings and updates the form to allow selection between BBR (Auto) and Brutal. Adds support for UDP masks, including model, serialization, and UI for adding/removing masks with type and password fields.
2026-01-18 17:38:05 +01:00
MHSanaei
2a76cec804
Add Hysteria2 outbound protocol support
Introduces support for the Hysteria2 protocol in outbound settings, including model, parsing, and form UI integration. Adds Hysteria2-specific stream and protocol settings, updates protocol selection, and enables configuration of Hysteria2 parameters in the outbound form.
2026-01-18 17:13:34 +01:00
MHSanaei
88eab032be
Add TUN protocol for inbound
Introduces TUN protocol to inbound.js, including a new TunSettings class. Updates inbound form to support TUN protocol and adds a dedicated form template for TUN settings. Translation files are updated with TUN-related strings for all supported languages.
2026-01-18 16:47:01 +01:00
MHSanaei
20ec863f51
Xray Core v26.1.18 2026-01-18 16:06:19 +01:00
Nebulosa
2f4018bbe5
feat: improve BBR management with sysctl.d and backup support (#3658) 2026-01-18 15:47:02 +01:00
Vorontsov Amadey
f273708f6d
Feature: Use of username and passwords consisting of several words (#3647) 2026-01-18 15:44:49 +01:00
Nebulosa
e6318d57e4
Add x-ui.service.arch file (#3650)
* Add a service file for Arch-based OSs

* Update release.yml with arch service file

* Update x-ui.service.arch
2026-01-18 15:41:07 +01:00
lolka1333
77fa976ee9
Enhance WebSocket client connection logic and improve event listener management (#3636)
- Updated WebSocketClient to allow connection during CONNECTING state.
- Introduced a flag for reconnection attempts.
- Improved event listener registration to prevent duplicate callbacks.
- Refactored online clients update logic in inbounds.html for better performance and clarity.
- Added CSS styles for subscription link boxes in subpage.html to enhance UI consistency and interactivity.

Co-authored-by: lolka1333 <test123@gmail.com>
2026-01-18 15:38:57 +01:00
MHSanaei
8098d2b1b1
Return nil if no error in GetXrayErr
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
Added a check to return nil immediately if p.GetErr() returns nil in GetXrayErr, preventing further error handling when no error is present.
2026-01-13 17:40:52 +01:00
VolgaIgor
a691eaea8d
Fixed incorrect filtering for IDN top-level domains (#3666)
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-01-12 02:53:43 +01:00
VolgaIgor
da447e5669
Added curl package to Dockerfile (#3665) 2026-01-11 20:18:54 +01:00
MHSanaei
f8c9aac97c
Add port selection and checks for ACME HTTP-01 listener
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
Introduces user prompts to select the port for ACME HTTP-01 certificate validation (default 80), checks if the chosen port is available, and provides guidance for port forwarding. Adds is_port_in_use helper to all scripts and improves messaging for certificate issuance and error handling.
2026-01-11 15:28:43 +01:00
MHSanaei
e42c17f2b2
Default listen address to 0.0.0.0 in GenXrayInboundConfig
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
When the listen address is empty, it now defaults to 0.0.0.0 to ensure proper dual-stack IPv4/IPv6 binding, improving compatibility on systems with bindv6only=0.
2026-01-09 20:22:33 +01:00
Nebulosa
427b7b67d8
Refactor ca-certificate dependency (#3655) 2026-01-09 17:05:55 +01:00
Nebulosa
ccf08086ac
refactor update geofiles fuctions (#3653) 2026-01-09 17:03:53 +01:00
64 changed files with 4170 additions and 1245 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

@ -18,6 +18,7 @@ on:
- 'go.mod' - 'go.mod'
- 'go.sum' - 'go.sum'
- 'x-ui.service.debian' - 'x-ui.service.debian'
- 'x-ui.service.arch'
- 'x-ui.service.rhel' - 'x-ui.service.rhel'
jobs: jobs:
@ -80,6 +81,7 @@ jobs:
mkdir x-ui mkdir x-ui
cp xui-release x-ui/ cp xui-release x-ui/
cp x-ui.service.debian x-ui/ cp x-ui.service.debian x-ui/
cp x-ui.service.arch x-ui/
cp x-ui.service.rhel x-ui/ cp x-ui.service.rhel x-ui/
cp x-ui.sh x-ui/ cp x-ui.sh x-ui/
mv x-ui/xui-release x-ui/x-ui mv x-ui/xui-release x-ui/x-ui
@ -87,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/v25.12.8/" 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
@ -171,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
shell: pwsh uses: msys2/setup-msys2@v2
with:
msystem: MINGW64
update: true
install: >-
mingw-w64-x86_64-gcc
mingw-w64-x86_64-sqlite3
mingw-w64-x86_64-pkg-config
- name: Build 3X-UI for Windows (CGO)
shell: msys2 {0}
run: | run: |
$env:CGO_ENABLED="1" export PATH="/c/hostedtoolcache/windows/go/$(ls /c/hostedtoolcache/windows/go | sort -V | tail -n1)/x64/bin:$PATH"
$env:GOOS="windows"
$env:GOARCH="amd64" export CGO_ENABLED=1
export GOOS=windows
export GOARCH=amd64
export CC=x86_64-w64-mingw32-gcc
which go
go version
gcc --version
go build -ldflags "-w -s" -o xui-release.exe -v main.go go build -ldflags "-w -s" -o xui-release.exe -v main.go
- name: Copy and download resources
shell: pwsh
run: |
mkdir x-ui mkdir x-ui
Copy-Item xui-release.exe x-ui\ Copy-Item xui-release.exe x-ui\x-ui.exe
mkdir x-ui\bin mkdir x-ui\bin
cd x-ui\bin cd x-ui\bin
# Download Xray for Windows # Download Xray for Windows
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v25.12.8/" $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/v25.12.8/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

@ -29,7 +29,9 @@ RUN apk add --no-cache --update \
ca-certificates \ ca-certificates \
tzdata \ tzdata \
fail2ban \ fail2ban \
bash bash \
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.7 2.8.10

View file

@ -80,9 +80,12 @@ type HistoryOfSeeders struct {
// GenXrayInboundConfig generates an Xray inbound configuration from the Inbound model. // GenXrayInboundConfig generates an Xray inbound configuration from the Inbound model.
func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig { func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
listen := i.Listen listen := i.Listen
if listen != "" { // Default to 0.0.0.0 (all interfaces) when listen is empty
listen = fmt.Sprintf("\"%v\"", listen) // This ensures proper dual-stack IPv4/IPv6 binding in systems where bindv6only=0
if listen == "" {
listen = "0.0.0.0"
} }
listen = fmt.Sprintf("\"%v\"", listen)
return &xray.InboundConfig{ return &xray.InboundConfig{
Listen: json_util.RawMessage(listen), Listen: json_util.RawMessage(listen),
Port: i.Port, Port: i.Port,

56
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.5 go 1.25.7
require ( require (
github.com/gin-contrib/gzip v1.2.5 github.com/gin-contrib/gzip v1.2.5
@ -11,20 +11,20 @@ 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.4.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
github.com/robfig/cron/v3 v3.0.1 github.com/robfig/cron/v3 v3.0.1
github.com/shirou/gopsutil/v4 v4.25.12 github.com/shirou/gopsutil/v4 v4.26.1
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.251208.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.46.0 golang.org/x/crypto v0.48.0
golang.org/x/sys v0.39.0 golang.org/x/sys v0.41.0
golang.org/x/text v0.32.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
@ -33,21 +33,21 @@ require (
require ( require (
github.com/Azure/go-ntlmssp v0.1.0 // indirect github.com/Azure/go-ntlmssp v0.1.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect github.com/andybalholm/brotli v1.2.0 // indirect
github.com/apernet/quic-go v0.57.2-0.20260111184307-eec823306178 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.14.2 // indirect github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.4.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudflare/circl v1.6.2 // 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/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33 // 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
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-yaml v1.19.1 // indirect github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/btree v1.1.3 // indirect github.com/google/btree v1.1.3 // indirect
github.com/gorilla/context v1.1.2 // indirect github.com/gorilla/context v1.1.2 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect
@ -57,30 +57,27 @@ 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.2 // 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.69 // 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.8.1 // 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.58.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect
github.com/refraction-networking/utls v1.8.1 // indirect github.com/refraction-networking/utls v1.8.2 // indirect
github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/sagernet/sing v0.7.14 // indirect github.com/sagernet/sing v0.7.18 // indirect
github.com/sagernet/sing-shadowsocks v0.2.9 // indirect github.com/sagernet/sing-shadowsocks v0.2.9 // indirect
github.com/seiflotfy/cuckoofilter v0.0.0-20240715131351-a2f2c23f1771 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect github.com/tklauser/numcpus v0.11.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect
github.com/v2fly/ss-bloomring v0.0.0-20210312155135-28617310f63e // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fastjson v1.6.7 // indirect github.com/valyala/fastjson v1.6.7 // indirect
github.com/vishvananda/netlink v1.3.1 // indirect github.com/vishvananda/netlink v1.3.1 // indirect
@ -88,16 +85,17 @@ 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/mod v0.31.0 // indirect golang.org/x/exp v0.0.0-20260209203927-2842357ff358 // indirect
golang.org/x/net v0.48.0 // indirect golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/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.40.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-20251222181119-0a764e51fe1b // 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-20250503011706-39ed1f5ac29c // indirect gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
lukechampine.com/blake3 v1.4.1 // indirect lukechampine.com/blake3 v1.4.1 // indirect
) )

111
go.sum
View file

@ -6,26 +6,25 @@ github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktp
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/apernet/quic-go v0.57.2-0.20260111184307-eec823306178 h1:bSq8n+gX4oO/qnM3MKf4kroW75n+phO9Qp6nigJKZ1E=
github.com/apernet/quic-go v0.57.2-0.20260111184307-eec823306178/go.mod h1:N1WIjPphkqs4efXWuyDNQ6OjjIK04vM3h+bEgwV+eVU=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980= github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o= github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-metro v0.0.0-20200812162917-85c65e2d0165/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw=
github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33 h1:ucRHb6/lvW/+mTEIGbvhcYU3S8+uSNkuMjx/qZFfhtM=
github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw=
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=
@ -57,8 +56,8 @@ github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy0
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.1 h1:3rG3+v8pkhRqoQ/88NYNMHYVGYztCOCIZ7UQhu7H+NE= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang/mock v1.7.0-rc.1 h1:YojYx61/OLFsiv6Rw1Z96LpldJIy31o+UHmwAUMJ6/U= github.com/golang/mock v1.7.0-rc.1 h1:YojYx61/OLFsiv6Rw1Z96LpldJIy31o+UHmwAUMJ6/U=
github.com/golang/mock v1.7.0-rc.1/go.mod h1:s42URUywIqd+OcERslBJvOjepvNymP31m3q8d/GkuRs= github.com/golang/mock v1.7.0-rc.1/go.mod h1:s42URUywIqd+OcERslBJvOjepvNymP31m3q8d/GkuRs=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
@ -106,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.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.2/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=
@ -120,17 +119,17 @@ 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.69 h1:Kb7Y/1Jo+SG+a2GtfoFUfDkG//csdRPwRLkCsxDG9Sc= github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.69/go.mod h1:7OyjD9nEba5OkqQ/hB4fy3PIoxafSZJtducccIelz3g= 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=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
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.4.0 h1:z74W5lfOTgLplQXuZPjDsRvvvI0iQatO2gp/XZz7s3I= github.com/mymmrac/telego v1.6.0 h1:Zc8rgyHozvd/7ZgyrigyHdAF9koHYMfilYfyB6wlFC0=
github.com/mymmrac/telego v1.4.0/go.mod h1:u9fKXZSOCOdMj6K0U69fQqeAvDE+2RGkHKkDksijp3o= 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=
@ -139,32 +138,28 @@ 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.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0= github.com/pires/go-proxyproto v0.11.0 h1:gUQpS85X/VJMdUsYyEgyn59uLJvGqPhJV5YvG68wXH4=
github.com/pires/go-proxyproto v0.8.1/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=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.58.0 h1:ggY2pvZaVdB9EyojxL1p+5mptkuHyX5MOSv4dgWF4Ug= github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.58.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/refraction-networking/utls v1.8.1 h1:yNY1kapmQU8JeM1sSw2H2asfTIwWxIkrMJI0pRUOCAo= github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
github.com/refraction-networking/utls v1.8.1/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 h1:f/FNXud6gA3MNr8meMVVGxhp+QBTqY91tM8HjEuMjGg=
github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3/go.mod h1:HgjTstvQsPGkxUsCd2KWxErBblirPizecHcpD3ffK+s=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sagernet/sing v0.7.14 h1:5QQRDCUvYNOMyVp3LuK/hYEBAIv0VsbD3x/l9zH467s= github.com/sagernet/sing v0.7.18 h1:iZHkaru1/MoHugx3G+9S3WG4owMewKO/KvieE2Pzk4E=
github.com/sagernet/sing v0.7.14/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak= github.com/sagernet/sing v0.7.18/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak=
github.com/sagernet/sing-shadowsocks v0.2.9 h1:Paep5zCszRKsEn8587O0MnhFWKJwDW1Y4zOYYlIxMkM= github.com/sagernet/sing-shadowsocks v0.2.9 h1:Paep5zCszRKsEn8587O0MnhFWKJwDW1Y4zOYYlIxMkM=
github.com/sagernet/sing-shadowsocks v0.2.9/go.mod h1:TE/Z6401Pi8tgr0nBZcM/xawAI6u3F6TTbz4nH/qw+8= github.com/sagernet/sing-shadowsocks v0.2.9/go.mod h1:TE/Z6401Pi8tgr0nBZcM/xawAI6u3F6TTbz4nH/qw+8=
github.com/seiflotfy/cuckoofilter v0.0.0-20240715131351-a2f2c23f1771 h1:emzAzMZ1L9iaKCTxdy3Em8Wv4ChIAGnfiz18Cda70g4= github.com/shirou/gopsutil/v4 v4.26.1 h1:TOkEyriIXk2HX9d4isZJtbjXbEjf5qyKPAzbzY0JWSo=
github.com/seiflotfy/cuckoofilter v0.0.0-20240715131351-a2f2c23f1771/go.mod h1:bR6DqgcAl1zTcOX8/pE2Qkj9XO00eCNqmKb7lXP8EAg= github.com/shirou/gopsutil/v4 v4.26.1/go.mod h1:medLI9/UNAb0dOI9Q3/7yWSqKkj00u+1tgY8nvv41pc=
github.com/shirou/gopsutil/v4 v4.25.12 h1:e7PvW/0RmJ8p8vPGJH4jvNkOyLmbkXgXW4m6ZPic6CY=
github.com/shirou/gopsutil/v4 v4.25.12/go.mod h1:EivAfP5x2EhLp2ovdpKSozecVXn1TmuG7SMzs/Wh4PU=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@ -172,7 +167,6 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
@ -187,8 +181,6 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/v2fly/ss-bloomring v0.0.0-20210312155135-28617310f63e h1:5QefA066A1tF8gHIiADmOVOV5LS43gt3ONnlEl3xkwI=
github.com/v2fly/ss-bloomring v0.0.0-20210312155135-28617310f63e/go.mod h1:5t19P9LBIrNamL6AcMQOncg/r10y3Pc01AbHeMhwlpU=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI= github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
@ -203,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.251208.0 h1:9jIXi+9KXnfmT5esSYNf9VAQlQkaAP8bG413B0eyAes= github.com/xtls/xray-core v1.260206.0 h1:gY8IV6u76CW93txL9QmacgZ0Udxr2Q3e9qUxXAhdHqI=
github.com/xtls/xray-core v1.251208.0/go.mod h1:kclzboEF0g6VBrp9/NXm8C0Aj64SDBt52OfthH1LSr4= 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=
@ -229,14 +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.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= golang.org/x/exp v0.0.0-20260209203927-2842357ff358 h1:kpfSV7uLwKJbFSEgNhWzGSL47NDSF/5pYYQw1V0ub6c=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/exp v0.0.0-20260209203927-2842357ff358/go.mod h1:R3t0oliuryB5eenPWl3rrQxwnNM3WTwnsRZZiXLAAW8=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0 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=
@ -245,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.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= 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.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= 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-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU= google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/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=
@ -271,14 +265,13 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c h1:m/r7OM+Y2Ty1sgBQ7Qb27VgIMBW8ZZhT4gLnUyDIhzI= gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 h1:Lk6hARj5UPY47dBep70OD/TIMwikJ5fGUGX0Rm3Xigk=
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g= gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q=
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo=

View file

@ -53,35 +53,52 @@ is_ip() {
is_ipv4 "$1" || is_ipv6 "$1" is_ipv4 "$1" || is_ipv6 "$1"
} }
is_domain() { is_domain() {
[[ "$1" =~ ^([A-Za-z0-9](-*[A-Za-z0-9])*\.)+[A-Za-z]{2,}$ ]] && return 0 || return 1 [[ "$1" =~ ^([A-Za-z0-9](-*[A-Za-z0-9])*\.)+(xn--[a-z0-9]{2,}|[A-Za-z]{2,})$ ]] && return 0 || return 1
}
# Port helpers
is_port_in_use() {
local port="$1"
if command -v ss >/dev/null 2>&1; then
ss -ltn 2>/dev/null | awk -v p=":${port}$" '$4 ~ p {exit 0} END {exit 1}'
return
fi
if command -v netstat >/dev/null 2>&1; then
netstat -lnt 2>/dev/null | awk -v p=":${port} " '$4 ~ p {exit 0} END {exit 1}'
return
fi
if command -v lsof >/dev/null 2>&1; then
lsof -nP -iTCP:${port} -sTCP:LISTEN >/dev/null 2>&1 && return 0
fi
return 1
} }
install_base() { install_base() {
case "${release}" in case "${release}" in
ubuntu | debian | armbian) ubuntu | debian | armbian)
apt-get update && apt-get install -y -q curl tar tzdata socat apt-get update && apt-get install -y -q curl tar tzdata socat ca-certificates
;; ;;
fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol) fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol)
dnf -y update && dnf install -y -q curl tar tzdata socat dnf -y update && dnf install -y -q curl tar tzdata socat ca-certificates
;; ;;
centos) centos)
if [[ "${VERSION_ID}" =~ ^7 ]]; then if [[ "${VERSION_ID}" =~ ^7 ]]; then
yum -y update && yum install -y curl tar tzdata socat yum -y update && yum install -y curl tar tzdata socat ca-certificates
else else
dnf -y update && dnf install -y -q curl tar tzdata socat dnf -y update && dnf install -y -q curl tar tzdata socat ca-certificates
fi fi
;; ;;
arch | manjaro | parch) arch | manjaro | parch)
pacman -Syu && pacman -Syu --noconfirm curl tar tzdata socat pacman -Syu && pacman -Syu --noconfirm curl tar tzdata socat ca-certificates
;; ;;
opensuse-tumbleweed | opensuse-leap) opensuse-tumbleweed | opensuse-leap)
zypper refresh && zypper -q install -y curl tar timezone socat zypper refresh && zypper -q install -y curl tar timezone socat ca-certificates
;; ;;
alpine) alpine)
apk update && apk add curl tar tzdata socat apk update && apk add curl tar tzdata socat ca-certificates
;; ;;
*) *)
apt-get update && apt-get install -y -q curl tar tzdata socat apt-get update && apt-get install -y -q curl tar tzdata socat ca-certificates
;; ;;
esac esac
} }
@ -130,7 +147,7 @@ setup_ssl_certificate() {
echo -e "${green}Issuing SSL certificate for ${domain}...${plain}" echo -e "${green}Issuing SSL certificate for ${domain}...${plain}"
echo -e "${yellow}Note: Port 80 must be open and accessible from the internet${plain}" echo -e "${yellow}Note: Port 80 must be open and accessible from the internet${plain}"
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt >/dev/null 2>&1 ~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force >/dev/null 2>&1
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport 80 --force ~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport 80 --force
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
@ -180,7 +197,7 @@ setup_ip_certificate() {
echo -e "${green}Setting up Let's Encrypt IP certificate (shortlived profile)...${plain}" echo -e "${green}Setting up Let's Encrypt IP certificate (shortlived profile)...${plain}"
echo -e "${yellow}Note: IP certificates are valid for ~6 days and will auto-renew.${plain}" echo -e "${yellow}Note: IP certificates are valid for ~6 days and will auto-renew.${plain}"
echo -e "${yellow}Port 80 must be open and accessible from the internet.${plain}" echo -e "${yellow}Default listener is port 80. If you choose another port, ensure external port 80 forwards to it.${plain}"
# Check for acme.sh # Check for acme.sh
if ! command -v ~/.acme.sh/acme.sh &>/dev/null; then if ! command -v ~/.acme.sh/acme.sh &>/dev/null; then
@ -216,9 +233,46 @@ setup_ip_certificate() {
# Set reload command for auto-renewal (add || true so it doesn't fail during first install) # Set reload command for auto-renewal (add || true so it doesn't fail during first install)
local reloadCmd="systemctl restart x-ui 2>/dev/null || rc-service x-ui restart 2>/dev/null || true" local reloadCmd="systemctl restart x-ui 2>/dev/null || rc-service x-ui restart 2>/dev/null || true"
# Choose port for HTTP-01 listener (default 80, prompt override)
local WebPort=""
read -rp "Port to use for ACME HTTP-01 listener (default 80): " WebPort
WebPort="${WebPort:-80}"
if ! [[ "${WebPort}" =~ ^[0-9]+$ ]] || ((WebPort < 1 || WebPort > 65535)); then
echo -e "${red}Invalid port provided. Falling back to 80.${plain}"
WebPort=80
fi
echo -e "${green}Using port ${WebPort} for standalone validation.${plain}"
if [[ "${WebPort}" -ne 80 ]]; then
echo -e "${yellow}Reminder: Let's Encrypt still connects on port 80; forward external port 80 to ${WebPort}.${plain}"
fi
# Ensure chosen port is available
while true; do
if is_port_in_use "${WebPort}"; then
echo -e "${yellow}Port ${WebPort} is in use.${plain}"
local alt_port=""
read -rp "Enter another port for acme.sh standalone listener (leave empty to abort): " alt_port
alt_port="${alt_port// /}"
if [[ -z "${alt_port}" ]]; then
echo -e "${red}Port ${WebPort} is busy; cannot proceed.${plain}"
return 1
fi
if ! [[ "${alt_port}" =~ ^[0-9]+$ ]] || ((alt_port < 1 || alt_port > 65535)); then
echo -e "${red}Invalid port provided.${plain}"
return 1
fi
WebPort="${alt_port}"
continue
else
echo -e "${green}Port ${WebPort} is free and ready for standalone validation.${plain}"
break
fi
done
# Issue certificate with shortlived profile # Issue certificate with shortlived profile
echo -e "${green}Issuing IP certificate for ${ipv4}...${plain}" echo -e "${green}Issuing IP certificate for ${ipv4}...${plain}"
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt >/dev/null 2>&1 ~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force >/dev/null 2>&1
~/.acme.sh/acme.sh --issue \ ~/.acme.sh/acme.sh --issue \
${domain_args} \ ${domain_args} \
@ -226,12 +280,12 @@ setup_ip_certificate() {
--server letsencrypt \ --server letsencrypt \
--certificate-profile shortlived \ --certificate-profile shortlived \
--days 6 \ --days 6 \
--httpport 80 \ --httpport ${WebPort} \
--force --force
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
echo -e "${red}Failed to issue IP certificate${plain}" echo -e "${red}Failed to issue IP certificate${plain}"
echo -e "${yellow}Please ensure port 80 is open and accessible from the internet${plain}" echo -e "${yellow}Please ensure port ${WebPort} is reachable (or forwarded from external port 80)${plain}"
# Cleanup acme.sh data for both IPv4 and IPv6 if specified # Cleanup acme.sh data for both IPv4 and IPv6 if specified
rm -rf ~/.acme.sh/${ipv4} 2>/dev/null rm -rf ~/.acme.sh/${ipv4} 2>/dev/null
[[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} 2>/dev/null [[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} 2>/dev/null
@ -360,7 +414,7 @@ ssl_cert_issue() {
systemctl stop x-ui 2>/dev/null || rc-service x-ui stop 2>/dev/null systemctl stop x-ui 2>/dev/null || rc-service x-ui stop 2>/dev/null
# issue the certificate # issue the certificate
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt ~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force ~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
echo -e "${red}Issuing certificate failed, please check logs.${plain}" echo -e "${red}Issuing certificate failed, please check logs.${plain}"
@ -467,12 +521,13 @@ prompt_and_setup_ssl() {
echo -e "${yellow}Choose SSL certificate setup method:${plain}" echo -e "${yellow}Choose SSL certificate setup method:${plain}"
echo -e "${green}1.${plain} Let's Encrypt for Domain (90-day validity, auto-renews)" echo -e "${green}1.${plain} Let's Encrypt for Domain (90-day validity, auto-renews)"
echo -e "${green}2.${plain} Let's Encrypt for IP Address (6-day validity, auto-renews)" echo -e "${green}2.${plain} Let's Encrypt for IP Address (6-day validity, auto-renews)"
echo -e "${blue}Note:${plain} Both options require port 80 open. IP certs use shortlived profile." echo -e "${green}3.${plain} Custom SSL Certificate (Path to existing files)"
echo -e "${blue}Note:${plain} Options 1 & 2 require port 80 open. Option 3 requires manual paths."
read -rp "Choose an option (default 2 for IP): " ssl_choice read -rp "Choose an option (default 2 for IP): " ssl_choice
ssl_choice="${ssl_choice// /}" # Trim whitespace ssl_choice="${ssl_choice// /}" # Trim whitespace
# Default to 2 (IP cert) if not 1 # Default to 2 (IP cert) if input is empty or invalid (not 1 or 3)
if [[ "$ssl_choice" != "1" ]]; then if [[ "$ssl_choice" != "1" && "$ssl_choice" != "3" ]]; then
ssl_choice="2" ssl_choice="2"
fi fi
@ -515,7 +570,66 @@ prompt_and_setup_ssl() {
echo -e "${red}✗ IP certificate setup failed. Please check port 80 is open.${plain}" echo -e "${red}✗ IP certificate setup failed. Please check port 80 is open.${plain}"
SSL_HOST="${server_ip}" SSL_HOST="${server_ip}"
fi fi
;;
3)
# User chose Custom Paths (User Provided) option
echo -e "${green}Using custom existing certificate...${plain}"
local custom_cert=""
local custom_key=""
local custom_domain=""
# 3.1 Request Domain to compose Panel URL later
read -rp "Please enter domain name certificate issued for: " custom_domain
custom_domain="${custom_domain// /}" # Убираем пробелы
# 3.2 Loop for Certificate Path
while true; do
read -rp "Input certificate path (keywords: .crt / fullchain): " custom_cert
# Strip quotes if present
custom_cert=$(echo "$custom_cert" | tr -d '"' | tr -d "'")
if [[ -f "$custom_cert" && -r "$custom_cert" && -s "$custom_cert" ]]; then
break
elif [[ ! -f "$custom_cert" ]]; then
echo -e "${red}Error: File does not exist! Try again.${plain}"
elif [[ ! -r "$custom_cert" ]]; then
echo -e "${red}Error: File exists but is not readable (check permissions)!${plain}"
else
echo -e "${red}Error: File is empty!${plain}"
fi
done
# 3.3 Loop for Private Key Path
while true; do
read -rp "Input private key path (keywords: .key / privatekey): " custom_key
# Strip quotes if present
custom_key=$(echo "$custom_key" | tr -d '"' | tr -d "'")
if [[ -f "$custom_key" && -r "$custom_key" && -s "$custom_key" ]]; then
break
elif [[ ! -f "$custom_key" ]]; then
echo -e "${red}Error: File does not exist! Try again.${plain}"
elif [[ ! -r "$custom_key" ]]; then
echo -e "${red}Error: File exists but is not readable (check permissions)!${plain}"
else
echo -e "${red}Error: File is empty!${plain}"
fi
done
# 3.4 Apply Settings via x-ui binary
${xui_folder}/x-ui cert -webCert "$custom_cert" -webCertKey "$custom_key" >/dev/null 2>&1
# Set SSL_HOST for composing Panel URL
if [[ -n "$custom_domain" ]]; then
SSL_HOST="$custom_domain"
else
SSL_HOST="${server_ip}"
fi
echo -e "${green}✓ Custom certificate paths applied.${plain}"
echo -e "${yellow}Note: You are responsible for renewing these files externally.${plain}"
systemctl restart x-ui >/dev/null 2>&1 || rc-service x-ui restart >/dev/null 2>&1
;; ;;
*) *)
echo -e "${red}Invalid option. Skipping SSL setup.${plain}" echo -e "${red}Invalid option. Skipping SSL setup.${plain}"
@ -540,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
@ -764,6 +881,15 @@ install_x-ui() {
fi fi
fi fi
;; ;;
arch | manjaro | parch)
if [ -f "x-ui.service.arch" ]; then
echo -e "${green}Found x-ui.service.arch in extracted files, installing...${plain}"
cp -f x-ui.service.arch ${xui_service}/x-ui.service >/dev/null 2>&1
if [[ $? -eq 0 ]]; then
service_installed=true
fi
fi
;;
*) *)
if [ -f "x-ui.service.rhel" ]; then if [ -f "x-ui.service.rhel" ]; then
echo -e "${green}Found x-ui.service.rhel in extracted files, installing...${plain}" echo -e "${green}Found x-ui.service.rhel in extracted files, installing...${plain}"
@ -783,6 +909,9 @@ install_x-ui() {
ubuntu | debian | armbian) ubuntu | debian | armbian)
curl -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.debian >/dev/null 2>&1 curl -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.debian >/dev/null 2>&1
;; ;;
arch | manjaro | parch)
curl -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.arch >/dev/null 2>&1
;;
*) *)
curl -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.rhel >/dev/null 2>&1 curl -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.rhel >/dev/null 2>&1
;; ;;

View file

@ -153,6 +153,31 @@ func (s *Server) initRouter() (*gin.Engine, error) {
SubTitle = "" SubTitle = ""
} }
SubSupportUrl, err := s.settingService.GetSubSupportUrl()
if err != nil {
SubSupportUrl = ""
}
SubProfileUrl, err := s.settingService.GetSubProfileUrl()
if err != nil {
SubProfileUrl = ""
}
SubAnnounce, err := s.settingService.GetSubAnnounce()
if err != nil {
SubAnnounce = ""
}
SubEnableRouting, err := s.settingService.GetSubEnableRouting()
if err != nil {
return nil, err
}
SubRoutingRules, err := s.settingService.GetSubRoutingRules()
if err != nil {
SubRoutingRules = ""
}
// set per-request localizer from headers/cookies // set per-request localizer from headers/cookies
engine.Use(locale.LocalizerMiddleware()) engine.Use(locale.LocalizerMiddleware())
@ -231,7 +256,8 @@ func (s *Server) initRouter() (*gin.Engine, error) {
s.sub = NewSUBController( s.sub = NewSUBController(
g, LinksPath, JsonPath, subJsonEnable, Encrypt, ShowInfo, RemarkModel, SubUpdates, g, LinksPath, JsonPath, subJsonEnable, Encrypt, ShowInfo, RemarkModel, SubUpdates,
SubJsonFragment, SubJsonNoises, SubJsonMux, SubJsonRules, SubTitle) SubJsonFragment, SubJsonNoises, SubJsonMux, SubJsonRules, SubTitle, SubSupportUrl,
SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules)
return engine, nil return engine, nil
} }

View file

@ -3,6 +3,7 @@ package sub
import ( import (
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"strconv"
"strings" "strings"
"github.com/mhsanaei/3x-ui/v2/config" "github.com/mhsanaei/3x-ui/v2/config"
@ -13,6 +14,11 @@ import (
// SUBController handles HTTP requests for subscription links and JSON configurations. // SUBController handles HTTP requests for subscription links and JSON configurations.
type SUBController struct { type SUBController struct {
subTitle string subTitle string
subSupportUrl string
subProfileUrl string
subAnnounce string
subEnableRouting bool
subRoutingRules string
subPath string subPath string
subJsonPath string subJsonPath string
jsonEnabled bool jsonEnabled bool
@ -38,10 +44,20 @@ func NewSUBController(
jsonMux string, jsonMux string,
jsonRules string, jsonRules string,
subTitle string, subTitle string,
subSupportUrl string,
subProfileUrl string,
subAnnounce string,
subEnableRouting bool,
subRoutingRules string,
) *SUBController { ) *SUBController {
sub := NewSubService(showInfo, rModel) sub := NewSubService(showInfo, rModel)
a := &SUBController{ a := &SUBController{
subTitle: subTitle, subTitle: subTitle,
subSupportUrl: subSupportUrl,
subProfileUrl: subProfileUrl,
subAnnounce: subAnnounce,
subEnableRouting: subEnableRouting,
subRoutingRules: subRoutingRules,
subPath: subPath, subPath: subPath,
subJsonPath: jsonPath, subJsonPath: jsonPath,
jsonEnabled: jsonEnabled, jsonEnabled: jsonEnabled,
@ -127,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) 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)))
@ -140,22 +160,54 @@ 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) 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)
} }
} }
// 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(c *gin.Context, header, updateInterval, profileTitle string) { func (a *SUBController) ApplyCommonHeaders(
c *gin.Context,
header,
updateInterval,
profileTitle string,
profileSupportUrl string,
profileUrl string,
profileAnnounce string,
profileEnableRouting bool,
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)
//Basics
if profileTitle != "" {
c.Writer.Header().Set("Profile-Title", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileTitle))) c.Writer.Header().Set("Profile-Title", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileTitle)))
} }
if profileSupportUrl != "" {
c.Writer.Header().Set("Support-Url", profileSupportUrl)
}
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))
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)
} }
} }

156
update.sh
View file

@ -78,7 +78,24 @@ is_ip() {
is_ipv4 "$1" || is_ipv6 "$1" is_ipv4 "$1" || is_ipv6 "$1"
} }
is_domain() { is_domain() {
[[ "$1" =~ ^([A-Za-z0-9](-*[A-Za-z0-9])*\.)+[A-Za-z]{2,}$ ]] && return 0 || return 1 [[ "$1" =~ ^([A-Za-z0-9](-*[A-Za-z0-9])*\.)+(xn--[a-z0-9]{2,}|[A-Za-z]{2,})$ ]] && return 0 || return 1
}
# Port helpers
is_port_in_use() {
local port="$1"
if command -v ss >/dev/null 2>&1; then
ss -ltn 2>/dev/null | awk -v p=":${port}$" '$4 ~ p {exit 0} END {exit 1}'
return
fi
if command -v netstat >/dev/null 2>&1; then
netstat -lnt 2>/dev/null | awk -v p=":${port} " '$4 ~ p {exit 0} END {exit 1}'
return
fi
if command -v lsof >/dev/null 2>&1; then
lsof -nP -iTCP:${port} -sTCP:LISTEN >/dev/null 2>&1 && return 0
fi
return 1
} }
gen_random_string() { gen_random_string() {
@ -156,7 +173,7 @@ setup_ssl_certificate() {
echo -e "${green}Issuing SSL certificate for ${domain}...${plain}" echo -e "${green}Issuing SSL certificate for ${domain}...${plain}"
echo -e "${yellow}Note: Port 80 must be open and accessible from the internet${plain}" echo -e "${yellow}Note: Port 80 must be open and accessible from the internet${plain}"
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt >/dev/null 2>&1 ~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force >/dev/null 2>&1
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport 80 --force ~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport 80 --force
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
@ -205,7 +222,7 @@ setup_ip_certificate() {
echo -e "${green}Setting up Let's Encrypt IP certificate (shortlived profile)...${plain}" echo -e "${green}Setting up Let's Encrypt IP certificate (shortlived profile)...${plain}"
echo -e "${yellow}Note: IP certificates are valid for ~6 days and will auto-renew.${plain}" echo -e "${yellow}Note: IP certificates are valid for ~6 days and will auto-renew.${plain}"
echo -e "${yellow}Port 80 must be open and accessible from the internet.${plain}" echo -e "${yellow}Default listener is port 80. If you choose another port, ensure external port 80 forwards to it.${plain}"
# Check for acme.sh # Check for acme.sh
if ! command -v ~/.acme.sh/acme.sh &>/dev/null; then if ! command -v ~/.acme.sh/acme.sh &>/dev/null; then
@ -241,9 +258,46 @@ setup_ip_certificate() {
# Set reload command for auto-renewal (add || true so it doesn't fail if service stopped) # Set reload command for auto-renewal (add || true so it doesn't fail if service stopped)
local reloadCmd="systemctl restart x-ui 2>/dev/null || rc-service x-ui restart 2>/dev/null || true" local reloadCmd="systemctl restart x-ui 2>/dev/null || rc-service x-ui restart 2>/dev/null || true"
# Choose port for HTTP-01 listener (default 80, prompt override)
local WebPort=""
read -rp "Port to use for ACME HTTP-01 listener (default 80): " WebPort
WebPort="${WebPort:-80}"
if ! [[ "${WebPort}" =~ ^[0-9]+$ ]] || ((WebPort < 1 || WebPort > 65535)); then
echo -e "${red}Invalid port provided. Falling back to 80.${plain}"
WebPort=80
fi
echo -e "${green}Using port ${WebPort} for standalone validation.${plain}"
if [[ "${WebPort}" -ne 80 ]]; then
echo -e "${yellow}Reminder: Let's Encrypt still connects on port 80; forward external port 80 to ${WebPort}.${plain}"
fi
# Ensure chosen port is available
while true; do
if is_port_in_use "${WebPort}"; then
echo -e "${yellow}Port ${WebPort} is currently in use.${plain}"
local alt_port=""
read -rp "Enter another port for acme.sh standalone listener (leave empty to abort): " alt_port
alt_port="${alt_port// /}"
if [[ -z "${alt_port}" ]]; then
echo -e "${red}Port ${WebPort} is busy; cannot proceed.${plain}"
return 1
fi
if ! [[ "${alt_port}" =~ ^[0-9]+$ ]] || ((alt_port < 1 || alt_port > 65535)); then
echo -e "${red}Invalid port provided.${plain}"
return 1
fi
WebPort="${alt_port}"
continue
else
echo -e "${green}Port ${WebPort} is free and ready for standalone validation.${plain}"
break
fi
done
# Issue certificate with shortlived profile # Issue certificate with shortlived profile
echo -e "${green}Issuing IP certificate for ${ipv4}...${plain}" echo -e "${green}Issuing IP certificate for ${ipv4}...${plain}"
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt >/dev/null 2>&1 ~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force >/dev/null 2>&1
~/.acme.sh/acme.sh --issue \ ~/.acme.sh/acme.sh --issue \
${domain_args} \ ${domain_args} \
@ -251,12 +305,12 @@ setup_ip_certificate() {
--server letsencrypt \ --server letsencrypt \
--certificate-profile shortlived \ --certificate-profile shortlived \
--days 6 \ --days 6 \
--httpport 80 \ --httpport ${WebPort} \
--force --force
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
echo -e "${red}Failed to issue IP certificate${plain}" echo -e "${red}Failed to issue IP certificate${plain}"
echo -e "${yellow}Please ensure port 80 is open and accessible from the internet${plain}" echo -e "${yellow}Please ensure port ${WebPort} is reachable (or forwarded from external port 80)${plain}"
# Cleanup acme.sh data for both IPv4 and IPv6 if specified # Cleanup acme.sh data for both IPv4 and IPv6 if specified
rm -rf ~/.acme.sh/${ipv4} 2>/dev/null rm -rf ~/.acme.sh/${ipv4} 2>/dev/null
[[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} 2>/dev/null [[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} 2>/dev/null
@ -383,7 +437,7 @@ ssl_cert_issue() {
systemctl stop x-ui 2>/dev/null || rc-service x-ui stop 2>/dev/null systemctl stop x-ui 2>/dev/null || rc-service x-ui stop 2>/dev/null
# issue the certificate # issue the certificate
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt ~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force ~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
echo -e "${red}Issuing certificate failed, please check logs.${plain}" echo -e "${red}Issuing certificate failed, please check logs.${plain}"
@ -487,12 +541,13 @@ prompt_and_setup_ssl() {
echo -e "${yellow}Choose SSL certificate setup method:${plain}" echo -e "${yellow}Choose SSL certificate setup method:${plain}"
echo -e "${green}1.${plain} Let's Encrypt for Domain (90-day validity, auto-renews)" echo -e "${green}1.${plain} Let's Encrypt for Domain (90-day validity, auto-renews)"
echo -e "${green}2.${plain} Let's Encrypt for IP Address (6-day validity, auto-renews)" echo -e "${green}2.${plain} Let's Encrypt for IP Address (6-day validity, auto-renews)"
echo -e "${blue}Note:${plain} Both options require port 80 open. IP certs use shortlived profile." echo -e "${green}3.${plain} Custom SSL Certificate (Path to existing files)"
echo -e "${blue}Note:${plain} Options 1 & 2 require port 80 open. Option 3 requires manual paths."
read -rp "Choose an option (default 2 for IP): " ssl_choice read -rp "Choose an option (default 2 for IP): " ssl_choice
ssl_choice="${ssl_choice// /}" # Trim whitespace ssl_choice="${ssl_choice// /}" # Trim whitespace
# Default to 2 (IP cert) if not 1 # Default to 2 (IP cert) if input is empty or invalid (not 1 or 3)
if [[ "$ssl_choice" != "1" ]]; then if [[ "$ssl_choice" != "1" && "$ssl_choice" != "3" ]]; then
ssl_choice="2" ssl_choice="2"
fi fi
@ -542,6 +597,67 @@ prompt_and_setup_ssl() {
else else
systemctl restart x-ui >/dev/null 2>&1 systemctl restart x-ui >/dev/null 2>&1
fi fi
;;
3)
# User chose Custom Paths (User Provided) option
echo -e "${green}Using custom existing certificate...${plain}"
local custom_cert=""
local custom_key=""
local custom_domain=""
# 3.1 Request Domain to compose Panel URL later
read -rp "Please enter domain name certificate issued for: " custom_domain
custom_domain="${custom_domain// /}" # Убираем пробелы
# 3.2 Loop for Certificate Path
while true; do
read -rp "Input certificate path (keywords: .crt / fullchain): " custom_cert
# Strip quotes if present
custom_cert=$(echo "$custom_cert" | tr -d '"' | tr -d "'")
if [[ -f "$custom_cert" && -r "$custom_cert" && -s "$custom_cert" ]]; then
break
elif [[ ! -f "$custom_cert" ]]; then
echo -e "${red}Error: File does not exist! Try again.${plain}"
elif [[ ! -r "$custom_cert" ]]; then
echo -e "${red}Error: File exists but is not readable (check permissions)!${plain}"
else
echo -e "${red}Error: File is empty!${plain}"
fi
done
# 3.3 Loop for Private Key Path
while true; do
read -rp "Input private key path (keywords: .key / privatekey): " custom_key
# Strip quotes if present
custom_key=$(echo "$custom_key" | tr -d '"' | tr -d "'")
if [[ -f "$custom_key" && -r "$custom_key" && -s "$custom_key" ]]; then
break
elif [[ ! -f "$custom_key" ]]; then
echo -e "${red}Error: File does not exist! Try again.${plain}"
elif [[ ! -r "$custom_key" ]]; then
echo -e "${red}Error: File exists but is not readable (check permissions)!${plain}"
else
echo -e "${red}Error: File is empty!${plain}"
fi
done
# 3.4 Apply Settings via x-ui binary
${xui_folder}/x-ui cert -webCert "$custom_cert" -webCertKey "$custom_key" >/dev/null 2>&1
# Set SSL_HOST for composing Panel URL
if [[ -n "$custom_domain" ]]; then
SSL_HOST="$custom_domain"
else
SSL_HOST="${server_ip}"
fi
echo -e "${green}✓ Custom certificate paths applied.${plain}"
echo -e "${yellow}Note: You are responsible for renewing these files externally.${plain}"
systemctl restart x-ui >/dev/null 2>&1 || rc-service x-ui restart >/dev/null 2>&1
;; ;;
*) *)
echo -e "${red}Invalid option. Skipping SSL setup.${plain}" echo -e "${red}Invalid option. Skipping SSL setup.${plain}"
@ -571,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
@ -683,6 +802,7 @@ update_x-ui() {
rm ${xui_folder} -f >/dev/null 2>&1 rm ${xui_folder} -f >/dev/null 2>&1
rm ${xui_folder}/x-ui.service -f >/dev/null 2>&1 rm ${xui_folder}/x-ui.service -f >/dev/null 2>&1
rm ${xui_folder}/x-ui.service.debian -f >/dev/null 2>&1 rm ${xui_folder}/x-ui.service.debian -f >/dev/null 2>&1
rm ${xui_folder}/x-ui.service.arch -f >/dev/null 2>&1
rm ${xui_folder}/x-ui.service.rhel -f >/dev/null 2>&1 rm ${xui_folder}/x-ui.service.rhel -f >/dev/null 2>&1
rm ${xui_folder}/x-ui -f >/dev/null 2>&1 rm ${xui_folder}/x-ui -f >/dev/null 2>&1
rm ${xui_folder}/x-ui.sh -f >/dev/null 2>&1 rm ${xui_folder}/x-ui.sh -f >/dev/null 2>&1
@ -765,6 +885,15 @@ update_x-ui() {
fi fi
fi fi
;; ;;
arch | manjaro | parch)
if [ -f "x-ui.service.arch" ]; then
echo -e "${green}Installing arch-like systemd unit...${plain}"
cp -f x-ui.service.arch ${xui_service}/x-ui.service >/dev/null 2>&1
if [[ $? -eq 0 ]]; then
service_installed=true
fi
fi
;;
*) *)
if [ -f "x-ui.service.rhel" ]; then if [ -f "x-ui.service.rhel" ]; then
echo -e "${green}Installing rhel-like systemd unit...${plain}" echo -e "${green}Installing rhel-like systemd unit...${plain}"
@ -783,6 +912,9 @@ update_x-ui() {
ubuntu | debian | armbian) ubuntu | debian | armbian)
${curl_bin} -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.debian >/dev/null 2>&1 ${curl_bin} -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.debian >/dev/null 2>&1
;; ;;
arch | manjaro | parch)
${curl_bin} -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.arch >/dev/null 2>&1
;;
*) *)
${curl_bin} -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.rhel >/dev/null 2>&1 ${curl_bin} -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.rhel >/dev/null 2>&1
;; ;;

View file

@ -7,6 +7,7 @@ const Protocols = {
MIXED: 'mixed', MIXED: 'mixed',
HTTP: 'http', HTTP: 'http',
WIREGUARD: 'wireguard', WIREGUARD: 'wireguard',
TUN: 'tun',
}; };
const SSMethods = { const SSMethods = {
@ -317,15 +318,13 @@ TcpStreamSettings.TcpResponse = class extends XrayCommonClass {
class KcpStreamSettings extends XrayCommonClass { class KcpStreamSettings extends XrayCommonClass {
constructor( constructor(
mtu = 1250, mtu = 1350,
tti = 50, tti = 20,
uplinkCapacity = 5, uplinkCapacity = 5,
downlinkCapacity = 20, downlinkCapacity = 20,
congestion = false, congestion = false,
readBufferSize = 2, readBufferSize = 1,
writeBufferSize = 2, writeBufferSize = 1,
type = 'none',
seed = RandomUtil.randomSeq(10),
) { ) {
super(); super();
this.mtu = mtu; this.mtu = mtu;
@ -335,8 +334,6 @@ class KcpStreamSettings extends XrayCommonClass {
this.congestion = congestion; this.congestion = congestion;
this.readBuffer = readBufferSize; this.readBuffer = readBufferSize;
this.writeBuffer = writeBufferSize; this.writeBuffer = writeBufferSize;
this.type = type;
this.seed = seed;
} }
static fromJson(json = {}) { static fromJson(json = {}) {
@ -348,8 +345,6 @@ class KcpStreamSettings extends XrayCommonClass {
json.congestion, json.congestion,
json.readBufferSize, json.readBufferSize,
json.writeBufferSize, json.writeBufferSize,
ObjectUtil.isEmpty(json.header) ? 'none' : json.header.type,
json.seed,
); );
} }
@ -362,10 +357,6 @@ class KcpStreamSettings extends XrayCommonClass {
congestion: this.congestion, congestion: this.congestion,
readBufferSize: this.readBuffer, readBufferSize: this.readBuffer,
writeBufferSize: this.writeBuffer, writeBufferSize: this.writeBuffer,
header: {
type: this.type,
},
seed: this.seed,
}; };
} }
} }
@ -496,6 +487,19 @@ class xHTTPStreamSettings extends XrayCommonClass {
noSSEHeader = false, noSSEHeader = false,
xPaddingBytes = "100-1000", xPaddingBytes = "100-1000",
mode = MODE_OPTION.AUTO, mode = MODE_OPTION.AUTO,
xPaddingObfsMode = false,
xPaddingKey = '',
xPaddingHeader = '',
xPaddingPlacement = '',
xPaddingMethod = '',
uplinkHTTPMethod = '',
sessionPlacement = '',
sessionKey = '',
seqPlacement = '',
seqKey = '',
uplinkDataPlacement = '',
uplinkDataKey = '',
uplinkChunkSize = 0,
) { ) {
super(); super();
this.path = path; this.path = path;
@ -507,6 +511,19 @@ class xHTTPStreamSettings extends XrayCommonClass {
this.noSSEHeader = noSSEHeader; this.noSSEHeader = noSSEHeader;
this.xPaddingBytes = xPaddingBytes; this.xPaddingBytes = xPaddingBytes;
this.mode = mode; this.mode = mode;
this.xPaddingObfsMode = xPaddingObfsMode;
this.xPaddingKey = xPaddingKey;
this.xPaddingHeader = xPaddingHeader;
this.xPaddingPlacement = xPaddingPlacement;
this.xPaddingMethod = xPaddingMethod;
this.uplinkHTTPMethod = uplinkHTTPMethod;
this.sessionPlacement = sessionPlacement;
this.sessionKey = sessionKey;
this.seqPlacement = seqPlacement;
this.seqKey = seqKey;
this.uplinkDataPlacement = uplinkDataPlacement;
this.uplinkDataKey = uplinkDataKey;
this.uplinkChunkSize = uplinkChunkSize;
} }
addHeader(name, value) { addHeader(name, value) {
@ -528,6 +545,19 @@ class xHTTPStreamSettings extends XrayCommonClass {
json.noSSEHeader, json.noSSEHeader,
json.xPaddingBytes, json.xPaddingBytes,
json.mode, json.mode,
json.xPaddingObfsMode,
json.xPaddingKey,
json.xPaddingHeader,
json.xPaddingPlacement,
json.xPaddingMethod,
json.uplinkHTTPMethod,
json.sessionPlacement,
json.sessionKey,
json.seqPlacement,
json.seqKey,
json.uplinkDataPlacement,
json.uplinkDataKey,
json.uplinkChunkSize,
); );
} }
@ -542,6 +572,19 @@ class xHTTPStreamSettings extends XrayCommonClass {
noSSEHeader: this.noSSEHeader, noSSEHeader: this.noSSEHeader,
xPaddingBytes: this.xPaddingBytes, xPaddingBytes: this.xPaddingBytes,
mode: this.mode, mode: this.mode,
xPaddingObfsMode: this.xPaddingObfsMode,
xPaddingKey: this.xPaddingKey,
xPaddingHeader: this.xPaddingHeader,
xPaddingPlacement: this.xPaddingPlacement,
xPaddingMethod: this.xPaddingMethod,
uplinkHTTPMethod: this.uplinkHTTPMethod,
sessionPlacement: this.sessionPlacement,
sessionKey: this.sessionKey,
seqPlacement: this.seqPlacement,
seqKey: this.seqKey,
uplinkDataPlacement: this.uplinkDataPlacement,
uplinkDataKey: this.uplinkDataKey,
uplinkChunkSize: this.uplinkChunkSize,
}; };
} }
} }
@ -553,7 +596,6 @@ class TlsStreamSettings extends XrayCommonClass {
maxVersion = TLS_VERSION_OPTION.TLS13, maxVersion = TLS_VERSION_OPTION.TLS13,
cipherSuites = '', cipherSuites = '',
rejectUnknownSni = false, rejectUnknownSni = false,
verifyPeerCertInNames = ['dns.google', 'cloudflare-dns.com'],
disableSystemRoot = false, disableSystemRoot = false,
enableSessionResumption = false, enableSessionResumption = false,
certificates = [new TlsStreamSettings.Cert()], certificates = [new TlsStreamSettings.Cert()],
@ -568,7 +610,6 @@ class TlsStreamSettings extends XrayCommonClass {
this.maxVersion = maxVersion; this.maxVersion = maxVersion;
this.cipherSuites = cipherSuites; this.cipherSuites = cipherSuites;
this.rejectUnknownSni = rejectUnknownSni; this.rejectUnknownSni = rejectUnknownSni;
this.verifyPeerCertInNames = Array.isArray(verifyPeerCertInNames) ? verifyPeerCertInNames.join(",") : verifyPeerCertInNames;
this.disableSystemRoot = disableSystemRoot; this.disableSystemRoot = disableSystemRoot;
this.enableSessionResumption = enableSessionResumption; this.enableSessionResumption = enableSessionResumption;
this.certs = certificates; this.certs = certificates;
@ -594,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,
@ -602,7 +643,6 @@ class TlsStreamSettings extends XrayCommonClass {
json.maxVersion, json.maxVersion,
json.cipherSuites, json.cipherSuites,
json.rejectUnknownSni, json.rejectUnknownSni,
json.verifyPeerCertInNames,
json.disableSystemRoot, json.disableSystemRoot,
json.enableSessionResumption, json.enableSessionResumption,
certs, certs,
@ -620,7 +660,6 @@ class TlsStreamSettings extends XrayCommonClass {
maxVersion: this.maxVersion, maxVersion: this.maxVersion,
cipherSuites: this.cipherSuites, cipherSuites: this.cipherSuites,
rejectUnknownSni: this.rejectUnknownSni, rejectUnknownSni: this.rejectUnknownSni,
verifyPeerCertInNames: this.verifyPeerCertInNames.split(","),
disableSystemRoot: this.disableSystemRoot, disableSystemRoot: this.disableSystemRoot,
enableSessionResumption: this.enableSessionResumption, enableSessionResumption: this.enableSessionResumption,
certificates: TlsStreamSettings.toJsonArray(this.certs), certificates: TlsStreamSettings.toJsonArray(this.certs),
@ -699,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
}; };
@ -928,6 +963,68 @@ class SockoptStreamSettings extends XrayCommonClass {
} }
} }
class UdpMask extends XrayCommonClass {
constructor(type = 'salamander', settings = {}) {
super();
this.type = type;
this.settings = this._getDefaultSettings(type, settings);
}
_getDefaultSettings(type, settings = {}) {
switch (type) {
case 'salamander':
case 'mkcp-aes128gcm':
return { password: settings.password || '' };
case 'header-dns':
case 'xdns':
return { domain: settings.domain || '' };
case 'xicmp':
return { ip: settings.ip || '', id: settings.id ?? 0 };
case 'mkcp-original':
case 'header-dtls':
case 'header-srtp':
case 'header-utp':
case 'header-wechat':
case 'header-wireguard':
return {};
default:
return settings;
}
}
static fromJson(json = {}) {
return new UdpMask(
json.type || 'salamander',
json.settings || {}
);
}
toJson() {
return {
type: this.type,
settings: (this.settings && Object.keys(this.settings).length > 0) ? this.settings : undefined
};
}
}
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())
};
}
}
class StreamSettings extends XrayCommonClass { class StreamSettings extends XrayCommonClass {
constructor(network = 'tcp', constructor(network = 'tcp',
security = 'none', security = 'none',
@ -940,6 +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 = new FinalMaskStreamSettings(),
sockopt = undefined, sockopt = undefined,
) { ) {
super(); super();
@ -954,9 +1052,24 @@ class StreamSettings extends XrayCommonClass {
this.grpc = grpcSettings; this.grpc = grpcSettings;
this.httpupgrade = httpupgradeSettings; this.httpupgrade = httpupgradeSettings;
this.xhttp = xhttpSettings; this.xhttp = xhttpSettings;
this.finalmask = finalmask;
this.sockopt = sockopt; this.sockopt = sockopt;
} }
addUdpMask(type = 'salamander') {
this.finalmask.udp.push(new UdpMask(type));
}
delUdpMask(index) {
if (this.finalmask.udp) {
this.finalmask.udp.splice(index, 1);
}
}
get hasFinalMask() {
return this.finalmask.udp && this.finalmask.udp.length > 0;
}
get isTls() { get isTls() {
return this.security === "tls"; return this.security === "tls";
} }
@ -1003,6 +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),
FinalMaskStreamSettings.fromJson(json.finalmask),
SockoptStreamSettings.fromJson(json.sockopt), SockoptStreamSettings.fromJson(json.sockopt),
); );
} }
@ -1021,6 +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.hasFinalMask ? this.finalmask.toJson() : undefined,
sockopt: this.sockopt != undefined ? this.sockopt.toJson() : undefined, sockopt: this.sockopt != undefined ? this.sockopt.toJson() : undefined,
}; };
} }
@ -1191,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;
} }
@ -1275,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;
@ -1308,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));
@ -1339,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;
@ -1373,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);
} }
@ -1444,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;
@ -1478,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);
} }
@ -1525,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;
@ -1559,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);
} }
@ -1739,6 +1826,7 @@ Inbound.Settings = class extends XrayCommonClass {
case Protocols.MIXED: return new Inbound.MixedSettings(protocol); case Protocols.MIXED: return new Inbound.MixedSettings(protocol);
case Protocols.HTTP: return new Inbound.HttpSettings(protocol); case Protocols.HTTP: return new Inbound.HttpSettings(protocol);
case Protocols.WIREGUARD: return new Inbound.WireguardSettings(protocol); case Protocols.WIREGUARD: return new Inbound.WireguardSettings(protocol);
case Protocols.TUN: return new Inbound.TunSettings(protocol);
default: return null; default: return null;
} }
} }
@ -1753,6 +1841,7 @@ Inbound.Settings = class extends XrayCommonClass {
case Protocols.MIXED: return Inbound.MixedSettings.fromJson(json); case Protocols.MIXED: return Inbound.MixedSettings.fromJson(json);
case Protocols.HTTP: return Inbound.HttpSettings.fromJson(json); case Protocols.HTTP: return Inbound.HttpSettings.fromJson(json);
case Protocols.WIREGUARD: return Inbound.WireguardSettings.fromJson(json); case Protocols.WIREGUARD: return Inbound.WireguardSettings.fromJson(json);
case Protocols.TUN: return Inbound.TunSettings.fromJson(json);
default: return null; default: return null;
} }
} }
@ -1944,7 +2033,9 @@ Inbound.VLESSSettings = class extends Inbound.Settings {
json.selectedAuth = this.selectedAuth; json.selectedAuth = this.selectedAuth;
} }
if (this.testseed && this.testseed.length >= 4) { // Only include testseed if at least one client has a flow set
const hasFlow = this.vlesses && this.vlesses.some(vless => vless.flow && vless.flow !== '');
if (hasFlow && this.testseed && this.testseed.length >= 4) {
json.testseed = this.testseed; json.testseed = this.testseed;
} }
@ -2506,7 +2597,7 @@ Inbound.HttpSettings.HttpAccount = class extends XrayCommonClass {
Inbound.WireguardSettings = class extends XrayCommonClass { Inbound.WireguardSettings = class extends XrayCommonClass {
constructor( constructor(
protocol, protocol,
mtu = 1250, mtu = 1420,
secretKey = Wireguard.generateKeypair().privateKey, secretKey = Wireguard.generateKeypair().privateKey,
peers = [new Inbound.WireguardSettings.Peer()], peers = [new Inbound.WireguardSettings.Peer()],
noKernelTun = false noKernelTun = false
@ -2586,3 +2677,34 @@ Inbound.WireguardSettings.Peer = class extends XrayCommonClass {
}; };
} }
}; };
Inbound.TunSettings = class extends Inbound.Settings {
constructor(
protocol,
name = 'xray0',
mtu = 1500,
userLevel = 0
) {
super(protocol);
this.name = name;
this.mtu = mtu;
this.userLevel = userLevel;
}
static fromJson(json = {}) {
return new Inbound.TunSettings(
Protocols.TUN,
json.name ?? 'xray0',
json.mtu ?? json.MTU ?? 1500,
json.userLevel ?? 0
);
}
toJson() {
return {
name: this.name || 'xray0',
mtu: this.mtu || 1500,
userLevel: this.userLevel || 0,
};
}
};

View file

@ -8,7 +8,8 @@ const Protocols = {
Shadowsocks: "shadowsocks", Shadowsocks: "shadowsocks",
Socks: "socks", Socks: "socks",
HTTP: "http", HTTP: "http",
Wireguard: "wireguard" Wireguard: "wireguard",
Hysteria: "hysteria"
}; };
const SSMethods = { const SSMethods = {
@ -164,15 +165,13 @@ class TcpStreamSettings extends CommonClass {
class KcpStreamSettings extends CommonClass { class KcpStreamSettings extends CommonClass {
constructor( constructor(
mtu = 1250, mtu = 1350,
tti = 50, tti = 20,
uplinkCapacity = 5, uplinkCapacity = 5,
downlinkCapacity = 20, downlinkCapacity = 20,
congestion = false, congestion = false,
readBufferSize = 2, readBufferSize = 1,
writeBufferSize = 2, writeBufferSize = 1,
type = 'none',
seed = '',
) { ) {
super(); super();
this.mtu = mtu; this.mtu = mtu;
@ -182,8 +181,6 @@ class KcpStreamSettings extends CommonClass {
this.congestion = congestion; this.congestion = congestion;
this.readBuffer = readBufferSize; this.readBuffer = readBufferSize;
this.writeBuffer = writeBufferSize; this.writeBuffer = writeBufferSize;
this.type = type;
this.seed = seed;
} }
static fromJson(json = {}) { static fromJson(json = {}) {
@ -195,8 +192,6 @@ class KcpStreamSettings extends CommonClass {
json.congestion, json.congestion,
json.readBufferSize, json.readBufferSize,
json.writeBufferSize, json.writeBufferSize,
ObjectUtil.isEmpty(json.header) ? 'none' : json.header.type,
json.seed,
); );
} }
@ -209,10 +204,6 @@ class KcpStreamSettings extends CommonClass {
congestion: this.congestion, congestion: this.congestion,
readBufferSize: this.readBuffer, readBufferSize: this.readBuffer,
writeBufferSize: this.writeBuffer, writeBufferSize: this.writeBuffer,
header: {
type: this.type,
},
seed: this.seed,
}; };
} }
} }
@ -354,15 +345,17 @@ class TlsStreamSettings extends CommonClass {
serverName = '', serverName = '',
alpn = [], alpn = [],
fingerprint = '', fingerprint = '',
allowInsecure = false,
echConfigList = '', echConfigList = '',
verifyPeerCertByName = '',
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.pinnedPeerCertSha256 = pinnedPeerCertSha256;
} }
static fromJson(json = {}) { static fromJson(json = {}) {
@ -370,8 +363,9 @@ 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.pinnedPeerCertSha256,
); );
} }
@ -380,8 +374,9 @@ 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,
pinnedPeerCertSha256: this.pinnedPeerCertSha256
}; };
} }
} }
@ -424,6 +419,102 @@ class RealityStreamSettings extends CommonClass {
}; };
} }
}; };
class HysteriaStreamSettings extends CommonClass {
constructor(
version = 2,
auth = '',
congestion = '',
up = '0',
down = '0',
udphopPort = '',
udphopIntervalMin = 30,
udphopIntervalMax = 30,
initStreamReceiveWindow = 8388608,
maxStreamReceiveWindow = 8388608,
initConnectionReceiveWindow = 20971520,
maxConnectionReceiveWindow = 20971520,
maxIdleTimeout = 30,
keepAlivePeriod = 0,
disablePathMTUDiscovery = false
) {
super();
this.version = version;
this.auth = auth;
this.congestion = congestion;
this.up = up;
this.down = down;
this.udphopPort = udphopPort;
this.udphopIntervalMin = udphopIntervalMin;
this.udphopIntervalMax = udphopIntervalMax;
this.initStreamReceiveWindow = initStreamReceiveWindow;
this.maxStreamReceiveWindow = maxStreamReceiveWindow;
this.initConnectionReceiveWindow = initConnectionReceiveWindow;
this.maxConnectionReceiveWindow = maxConnectionReceiveWindow;
this.maxIdleTimeout = maxIdleTimeout;
this.keepAlivePeriod = keepAlivePeriod;
this.disablePathMTUDiscovery = disablePathMTUDiscovery;
}
static fromJson(json = {}) {
let udphopPort = '';
let udphopIntervalMin = 30;
let udphopIntervalMax = 30;
if (json.udphop) {
udphopPort = json.udphop.port || '';
// Backward compatibility: if old 'interval' exists, use it for both min/max
if (json.udphop.interval !== undefined) {
udphopIntervalMin = json.udphop.interval;
udphopIntervalMax = json.udphop.interval;
} else {
udphopIntervalMin = json.udphop.intervalMin || 30;
udphopIntervalMax = json.udphop.intervalMax || 30;
}
}
return new HysteriaStreamSettings(
json.version,
json.auth,
json.congestion,
json.up,
json.down,
udphopPort,
udphopIntervalMin,
udphopIntervalMax,
json.initStreamReceiveWindow,
json.maxStreamReceiveWindow,
json.initConnectionReceiveWindow,
json.maxConnectionReceiveWindow,
json.maxIdleTimeout,
json.keepAlivePeriod,
json.disablePathMTUDiscovery
);
}
toJson() {
const result = {
version: this.version,
auth: this.auth,
congestion: this.congestion,
up: this.up,
down: this.down,
initStreamReceiveWindow: this.initStreamReceiveWindow,
maxStreamReceiveWindow: this.maxStreamReceiveWindow,
initConnectionReceiveWindow: this.initConnectionReceiveWindow,
maxConnectionReceiveWindow: this.maxConnectionReceiveWindow,
maxIdleTimeout: this.maxIdleTimeout,
keepAlivePeriod: this.keepAlivePeriod,
disablePathMTUDiscovery: this.disablePathMTUDiscovery
};
if (this.udphopPort) {
result.udphop = {
port: this.udphopPort,
intervalMin: this.udphopIntervalMin,
intervalMax: this.udphopIntervalMax
};
}
return result;
}
};
class SockoptStreamSettings extends CommonClass { class SockoptStreamSettings extends CommonClass {
constructor( constructor(
dialerProxy = "", dialerProxy = "",
@ -473,6 +564,66 @@ class SockoptStreamSettings extends CommonClass {
} }
} }
class UdpMask extends CommonClass {
constructor(type = 'salamander', settings = {}) {
super();
this.type = type;
this.settings = this._getDefaultSettings(type, settings);
}
_getDefaultSettings(type, settings = {}) {
switch (type) {
case 'salamander':
case 'mkcp-aes128gcm':
return { password: settings.password || '' };
case 'header-dns':
case 'xdns':
return { domain: settings.domain || '' };
case 'mkcp-original':
case 'header-dtls':
case 'header-srtp':
case 'header-utp':
case 'header-wechat':
case 'header-wireguard':
return {}; // No settings needed
default:
return settings;
}
}
static fromJson(json = {}) {
return new UdpMask(
json.type || 'salamander',
json.settings || {}
);
}
toJson() {
return {
type: this.type,
settings: (this.settings && Object.keys(this.settings).length > 0) ? this.settings : undefined
};
}
}
class FinalMaskStreamSettings extends CommonClass {
constructor(udp = []) {
super();
this.udp = Array.isArray(udp) ? udp.map(u => new UdpMask(u.type, u.settings)) : [new UdpMask(udp.type, udp.settings)];
}
static fromJson(json = {}) {
return new FinalMaskStreamSettings(json.udp || []);
}
toJson() {
return {
udp: this.udp.map(udp => udp.toJson())
};
}
}
class StreamSettings extends CommonClass { class StreamSettings extends CommonClass {
constructor( constructor(
network = 'tcp', network = 'tcp',
@ -485,6 +636,8 @@ class StreamSettings extends CommonClass {
grpcSettings = new GrpcStreamSettings(), grpcSettings = new GrpcStreamSettings(),
httpupgradeSettings = new HttpUpgradeStreamSettings(), httpupgradeSettings = new HttpUpgradeStreamSettings(),
xhttpSettings = new xHTTPStreamSettings(), xhttpSettings = new xHTTPStreamSettings(),
hysteriaSettings = new HysteriaStreamSettings(),
finalmask = new FinalMaskStreamSettings(),
sockopt = undefined, sockopt = undefined,
) { ) {
super(); super();
@ -498,9 +651,25 @@ class StreamSettings extends CommonClass {
this.grpc = grpcSettings; this.grpc = grpcSettings;
this.httpupgrade = httpupgradeSettings; this.httpupgrade = httpupgradeSettings;
this.xhttp = xhttpSettings; this.xhttp = xhttpSettings;
this.hysteria = hysteriaSettings;
this.finalmask = finalmask;
this.sockopt = sockopt; this.sockopt = sockopt;
} }
addUdpMask(type = 'salamander') {
this.finalmask.udp.push(new UdpMask(type));
}
delUdpMask(index) {
if (this.finalmask.udp) {
this.finalmask.udp.splice(index, 1);
}
}
get hasFinalMask() {
return this.finalmask.udp && this.finalmask.udp.length > 0;
}
get isTls() { get isTls() {
return this.security === 'tls'; return this.security === 'tls';
} }
@ -529,6 +698,8 @@ class StreamSettings extends CommonClass {
GrpcStreamSettings.fromJson(json.grpcSettings), GrpcStreamSettings.fromJson(json.grpcSettings),
HttpUpgradeStreamSettings.fromJson(json.httpupgradeSettings), HttpUpgradeStreamSettings.fromJson(json.httpupgradeSettings),
xHTTPStreamSettings.fromJson(json.xhttpSettings), xHTTPStreamSettings.fromJson(json.xhttpSettings),
HysteriaStreamSettings.fromJson(json.hysteriaSettings),
FinalMaskStreamSettings.fromJson(json.finalmask),
SockoptStreamSettings.fromJson(json.sockopt), SockoptStreamSettings.fromJson(json.sockopt),
); );
} }
@ -546,6 +717,8 @@ class StreamSettings extends CommonClass {
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,
hysteriaSettings: network === 'hysteria' ? this.hysteria.toJson() : undefined,
finalmask: this.hasFinalMask ? this.finalmask.toJson() : undefined,
sockopt: this.sockopt != undefined ? this.sockopt.toJson() : undefined, sockopt: this.sockopt != undefined ? this.sockopt.toJson() : undefined,
}; };
} }
@ -609,7 +782,8 @@ class Outbound extends CommonClass {
} }
canEnableTls() { canEnableTls() {
if (![Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(this.protocol)) return false; if (![Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks, Protocols.Hysteria].includes(this.protocol)) return false;
if (this.protocol === Protocols.Hysteria) return this.stream.network === 'hysteria';
return ["tcp", "ws", "http", "grpc", "httpupgrade", "xhttp"].includes(this.stream.network); return ["tcp", "ws", "http", "grpc", "httpupgrade", "xhttp"].includes(this.stream.network);
} }
@ -634,7 +808,7 @@ class Outbound extends CommonClass {
} }
canEnableStream() { canEnableStream() {
return [Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(this.protocol); return [Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks, Protocols.Hysteria].includes(this.protocol);
} }
canEnableMux() { canEnableMux() {
@ -673,7 +847,8 @@ class Outbound extends CommonClass {
Protocols.Trojan, Protocols.Trojan,
Protocols.Shadowsocks, Protocols.Shadowsocks,
Protocols.Socks, Protocols.Socks,
Protocols.HTTP Protocols.HTTP,
Protocols.Hysteria
].includes(this.protocol); ].includes(this.protocol);
} }
@ -722,6 +897,9 @@ class Outbound extends CommonClass {
case Protocols.Trojan: case Protocols.Trojan:
case 'ss': case 'ss':
return this.fromParamLink(link); return this.fromParamLink(link);
case 'hysteria2':
case Protocols.Hysteria:
return this.fromHysteriaLink(link);
default: default:
return null; return null;
} }
@ -754,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;
@ -796,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') {
@ -842,6 +1018,70 @@ class Outbound extends CommonClass {
remark = remark.length > 0 ? remark.substring(1) : 'out-' + protocol + '-' + port; remark = remark.length > 0 ? remark.substring(1) : 'out-' + protocol + '-' + port;
return new Outbound(remark, protocol, settings, stream); return new Outbound(remark, protocol, settings, stream);
} }
static fromHysteriaLink(link) {
// Parse hysteria2://password@address:port[?param1=value1&param2=value2...][#remarks]
const regex = /^hysteria2?:\/\/([^@]+)@([^:?#]+):(\d+)([^#]*)(#.*)?$/;
const match = link.match(regex);
if (!match) return null;
let [, password, address, port, params, hash] = match;
port = parseInt(port);
// Parse URL parameters if present
let urlParams = new URLSearchParams(params);
// Create stream settings with hysteria network
let stream = new StreamSettings('hysteria', 'none');
// Set hysteria stream settings
stream.hysteria.auth = password;
stream.hysteria.congestion = urlParams.get('congestion') ?? '';
stream.hysteria.up = urlParams.get('up') ?? '0';
stream.hysteria.down = urlParams.get('down') ?? '0';
stream.hysteria.udphopPort = urlParams.get('udphopPort') ?? '';
// Support both old single interval and new min/max range
if (urlParams.has('udphopInterval')) {
const interval = parseInt(urlParams.get('udphopInterval'));
stream.hysteria.udphopIntervalMin = interval;
stream.hysteria.udphopIntervalMax = interval;
} else {
stream.hysteria.udphopIntervalMin = parseInt(urlParams.get('udphopIntervalMin') ?? '30');
stream.hysteria.udphopIntervalMax = parseInt(urlParams.get('udphopIntervalMax') ?? '30');
}
// Optional QUIC parameters
if (urlParams.has('initStreamReceiveWindow')) {
stream.hysteria.initStreamReceiveWindow = parseInt(urlParams.get('initStreamReceiveWindow'));
}
if (urlParams.has('maxStreamReceiveWindow')) {
stream.hysteria.maxStreamReceiveWindow = parseInt(urlParams.get('maxStreamReceiveWindow'));
}
if (urlParams.has('initConnectionReceiveWindow')) {
stream.hysteria.initConnectionReceiveWindow = parseInt(urlParams.get('initConnectionReceiveWindow'));
}
if (urlParams.has('maxConnectionReceiveWindow')) {
stream.hysteria.maxConnectionReceiveWindow = parseInt(urlParams.get('maxConnectionReceiveWindow'));
}
if (urlParams.has('maxIdleTimeout')) {
stream.hysteria.maxIdleTimeout = parseInt(urlParams.get('maxIdleTimeout'));
}
if (urlParams.has('keepAlivePeriod')) {
stream.hysteria.keepAlivePeriod = parseInt(urlParams.get('keepAlivePeriod'));
}
if (urlParams.has('disablePathMTUDiscovery')) {
stream.hysteria.disablePathMTUDiscovery = urlParams.get('disablePathMTUDiscovery') === 'true';
}
// Create settings
let settings = new Outbound.HysteriaSettings(address, port, 2);
// Extract remark from hash
let remark = hash ? decodeURIComponent(hash.substring(1)) : `out-hysteria-${port}`;
return new Outbound(remark, Protocols.Hysteria, settings, stream);
}
} }
Outbound.Settings = class extends CommonClass { Outbound.Settings = class extends CommonClass {
@ -862,6 +1102,7 @@ Outbound.Settings = class extends CommonClass {
case Protocols.Socks: return new Outbound.SocksSettings(); case Protocols.Socks: return new Outbound.SocksSettings();
case Protocols.HTTP: return new Outbound.HttpSettings(); case Protocols.HTTP: return new Outbound.HttpSettings();
case Protocols.Wireguard: return new Outbound.WireguardSettings(); case Protocols.Wireguard: return new Outbound.WireguardSettings();
case Protocols.Hysteria: return new Outbound.HysteriaSettings();
default: return null; default: return null;
} }
} }
@ -878,6 +1119,7 @@ Outbound.Settings = class extends CommonClass {
case Protocols.Socks: return Outbound.SocksSettings.fromJson(json); case Protocols.Socks: return Outbound.SocksSettings.fromJson(json);
case Protocols.HTTP: return Outbound.HttpSettings.fromJson(json); case Protocols.HTTP: return Outbound.HttpSettings.fromJson(json);
case Protocols.Wireguard: return Outbound.WireguardSettings.fromJson(json); case Protocols.Wireguard: return Outbound.WireguardSettings.fromJson(json);
case Protocols.Hysteria: return Outbound.HysteriaSettings.fromJson(json);
default: return null; default: return null;
} }
} }
@ -1096,12 +1338,15 @@ Outbound.VLESSSettings = class extends CommonClass {
flow: this.flow, flow: this.flow,
encryption: this.encryption, encryption: this.encryption,
}; };
// Only include Vision settings when flow is set
if (this.flow && this.flow !== '') {
if (this.testpre > 0) { if (this.testpre > 0) {
result.testpre = this.testpre; result.testpre = this.testpre;
} }
if (this.testseed && this.testseed.length >= 4) { if (this.testseed && this.testseed.length >= 4) {
result.testseed = this.testseed; result.testseed = this.testseed;
} }
}
return result; return result;
} }
}; };
@ -1233,7 +1478,7 @@ Outbound.HttpSettings = class extends CommonClass {
Outbound.WireguardSettings = class extends CommonClass { Outbound.WireguardSettings = class extends CommonClass {
constructor( constructor(
mtu = 1250, mtu = 1420,
secretKey = '', secretKey = '',
address = [''], address = [''],
workers = 2, workers = 2,
@ -1325,3 +1570,29 @@ Outbound.WireguardSettings.Peer = class extends CommonClass {
}; };
} }
}; };
Outbound.HysteriaSettings = class extends CommonClass {
constructor(address = '', port = 443, version = 2) {
super();
this.address = address;
this.port = port;
this.version = version;
}
static fromJson(json = {}) {
if (Object.keys(json).length === 0) return new Outbound.HysteriaSettings();
return new Outbound.HysteriaSettings(
json.address,
json.port,
json.version
);
}
toJson() {
return {
address: this.address,
port: this.port,
version: this.version
};
}
};

View file

@ -1,18 +1,15 @@
// List of popular services for VLESS Reality Target/SNI randomization // List of popular services for VLESS Reality Target/SNI randomization
const REALITY_TARGETS = [ const REALITY_TARGETS = [
{ target: 'www.icloud.com:443', sni: 'www.icloud.com,icloud.com' }, { target: 'www.apple.com:443', sni: 'www.apple.com' },
{ target: 'www.apple.com:443', sni: 'www.apple.com,apple.com' }, { target: 'www.icloud.com:443', sni: 'www.icloud.com' },
{ target: 'www.tesla.com:443', sni: 'www.tesla.com,tesla.com' }, { target: 'www.amazon.com:443', sni: 'www.amazon.com' },
{ target: 'www.sony.com:443', sni: 'www.sony.com,sony.com' }, { target: 'aws.amazon.com:443', sni: 'aws.amazon.com' },
{ target: 'www.nvidia.com:443', sni: 'www.nvidia.com,nvidia.com' }, { target: 'www.oracle.com:443', sni: 'www.oracle.com' },
{ target: 'www.amd.com:443', sni: 'www.amd.com,amd.com' }, { target: 'www.nvidia.com:443', sni: 'www.nvidia.com' },
{ target: 'azure.microsoft.com:443', sni: 'azure.microsoft.com,www.azure.com' }, { target: 'www.amd.com:443', sni: 'www.amd.com' },
{ target: 'aws.amazon.com:443', sni: 'aws.amazon.com,amazon.com' }, { target: 'www.intel.com:443', sni: 'www.intel.com' },
{ target: 'www.bing.com:443', sni: 'www.bing.com,bing.com' }, { target: 'www.tesla.com:443', sni: 'www.tesla.com' },
{ target: 'www.oracle.com:443', sni: 'www.oracle.com,oracle.com' }, { target: 'www.sony.com:443', sni: 'www.sony.com' }
{ target: 'www.intel.com:443', sni: 'www.intel.com,intel.com' },
{ target: 'www.microsoft.com:443', sni: 'www.microsoft.com,microsoft.com' },
{ target: 'www.amazon.com:443', sni: 'www.amazon.com,amazon.com' }
]; ];
/** /**
@ -28,4 +25,3 @@ function getRandomRealityTarget() {
sni: selected.sni sni: selected.sni
}; };
} }

View file

@ -29,6 +29,11 @@ class AllSetting {
this.subEnable = true; this.subEnable = true;
this.subJsonEnable = false; this.subJsonEnable = false;
this.subTitle = ""; this.subTitle = "";
this.subSupportUrl = "";
this.subProfileUrl = "";
this.subAnnounce = "";
this.subEnableRouting = true;
this.subRoutingRules = "";
this.subListen = ""; this.subListen = "";
this.subPort = 2096; this.subPort = 2096;
this.subPath = "/sub/"; this.subPath = "/sub/";

View file

@ -14,10 +14,12 @@ class WebSocketClient {
} }
connect() { connect() {
if (this.ws && this.ws.readyState === WebSocket.OPEN) { if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
return; return;
} }
this.shouldReconnect = true;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
// Ensure basePath ends with '/' for proper URL construction // Ensure basePath ends with '/' for proper URL construction
let basePath = this.basePath || ''; let basePath = this.basePath || '';
@ -97,7 +99,10 @@ class WebSocketClient {
if (!this.listeners.has(event)) { if (!this.listeners.has(event)) {
this.listeners.set(event, []); this.listeners.set(event, []);
} }
this.listeners.get(event).push(callback); const callbacks = this.listeners.get(event);
if (!callbacks.includes(callback)) {
callbacks.push(callback);
}
} }
off(event, callback) { off(event, callback) {

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

@ -57,6 +57,11 @@ type AllSetting struct {
SubEnable bool `json:"subEnable" form:"subEnable"` // Enable subscription server SubEnable bool `json:"subEnable" form:"subEnable"` // Enable subscription server
SubJsonEnable bool `json:"subJsonEnable" form:"subJsonEnable"` // Enable JSON subscription endpoint SubJsonEnable bool `json:"subJsonEnable" form:"subJsonEnable"` // Enable JSON subscription endpoint
SubTitle string `json:"subTitle" form:"subTitle"` // Subscription title SubTitle string `json:"subTitle" form:"subTitle"` // Subscription title
SubSupportUrl string `json:"subSupportUrl" form:"subSupportUrl"` // Subscription support URL
SubProfileUrl string `json:"subProfileUrl" form:"subProfileUrl"` // Subscription profile URL
SubAnnounce string `json:"subAnnounce" form:"subAnnounce"` // Subscription announce
SubEnableRouting bool `json:"subEnableRouting" form:"subEnableRouting"` // Enable routing for subscription
SubRoutingRules string `json:"subRoutingRules" form:"subRoutingRules"` // Subscription global routing rules (Only for Happ)
SubListen string `json:"subListen" form:"subListen"` // Subscription server listen IP SubListen string `json:"subListen" form:"subListen"` // Subscription server listen IP
SubPort int `json:"subPort" form:"subPort"` // Subscription server port SubPort int `json:"subPort" form:"subPort"` // Subscription server port
SubPath string `json:"subPath" form:"subPath"` // Base path for subscription URLs SubPath string `json:"subPath" form:"subPath"` // Base path for subscription URLs

View file

@ -1,6 +1,7 @@
{{define "form/inbound"}} {{define "form/inbound"}}
<!-- base --> <!-- base -->
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }"> <a-form :colon="false" :label-col="{ md: {span:8} }"
:wrapper-col="{ md: {span:14} }">
<a-form-item label='{{ i18n "enable" }}'> <a-form-item label='{{ i18n "enable" }}'>
<a-switch v-model="dbInbound.enable"></a-switch> <a-switch v-model="dbInbound.enable"></a-switch>
</a-form-item> </a-form-item>
@ -9,8 +10,10 @@
</a-form-item> </a-form-item>
<a-form-item label='{{ i18n "protocol" }}'> <a-form-item label='{{ i18n "protocol" }}'>
<a-select v-model="inbound.protocol" :disabled="isEdit" :dropdown-class-name="themeSwitcher.currentTheme"> <a-select v-model="inbound.protocol" :disabled="isEdit"
<a-select-option v-for="p in Protocols" :key="p" :value="p">[[ p ]]</a-select-option> :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="p in Protocols" :key="p" :value="p">[[ p
]]</a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
@ -28,7 +31,8 @@
</a-form-item> </a-form-item>
<a-form-item label='{{ i18n "pages.inbounds.port" }}'> <a-form-item label='{{ i18n "pages.inbounds.port" }}'>
<a-input-number v-model.number="inbound.port" :min="1" :max="65535"></a-input-number> <a-input-number v-model.number="inbound.port" :min="1"
:max="65535"></a-input-number>
</a-form-item> </a-form-item>
<a-form-item> <a-form-item>
@ -41,29 +45,42 @@
<a-icon type="question-circle"></a-icon> <a-icon type="question-circle"></a-icon>
</a-tooltip> </a-tooltip>
</template> </template>
<a-input-number v-model.number="dbInbound.totalGB" :min="0"></a-input-number> <a-input-number v-model.number="dbInbound.totalGB"
:min="0"></a-input-number>
</a-form-item> </a-form-item>
<a-form-item> <a-form-item>
<template slot="label"> <template slot="label">
<a-tooltip> <a-tooltip>
<template slot="title"> <template slot="title">
<span>{{ i18n "pages.inbounds.periodicTrafficResetDesc" }}</span> <span>{{ i18n "pages.inbounds.periodicTrafficResetDesc"
<br v-if="dbInbound.lastTrafficResetTime && dbInbound.lastTrafficResetTime > 0"> }}</span>
<span v-if="dbInbound.lastTrafficResetTime && dbInbound.lastTrafficResetTime > 0"> <br
v-if="dbInbound.lastTrafficResetTime && dbInbound.lastTrafficResetTime > 0">
<span
v-if="dbInbound.lastTrafficResetTime && dbInbound.lastTrafficResetTime > 0">
<strong>{{ i18n "pages.inbounds.lastReset" }}:</strong> <strong>{{ i18n "pages.inbounds.lastReset" }}:</strong>
<span>[[ IntlUtil.formatDate(dbInbound.lastTrafficResetTime) ]]</span> <span>[[
IntlUtil.formatDate(dbInbound.lastTrafficResetTime)
]]</span>
</span> </span>
</template> </template>
{{ i18n "pages.inbounds.periodicTrafficResetTitle" }} {{ i18n "pages.inbounds.periodicTrafficResetTitle" }}
<a-icon type="question-circle"></a-icon> <a-icon type="question-circle"></a-icon>
</a-tooltip> </a-tooltip>
</template> </template>
<a-select v-model="dbInbound.trafficReset" :dropdown-class-name="themeSwitcher.currentTheme"> <a-select v-model="dbInbound.trafficReset"
<a-select-option value="never">{{ i18n "pages.inbounds.periodicTrafficReset.never" }}</a-select-option> :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value="daily">{{ i18n "pages.inbounds.periodicTrafficReset.daily" }}</a-select-option> <a-select-option value="never">{{ i18n
<a-select-option value="weekly">{{ i18n "pages.inbounds.periodicTrafficReset.weekly" }}</a-select-option> "pages.inbounds.periodicTrafficReset.never" }}</a-select-option>
<a-select-option value="monthly">{{ i18n "pages.inbounds.periodicTrafficReset.monthly" }}</a-select-option> <a-select-option value="daily">{{ i18n
"pages.inbounds.periodicTrafficReset.daily" }}</a-select-option>
<a-select-option value="weekly">{{ i18n
"pages.inbounds.periodicTrafficReset.weekly"
}}</a-select-option>
<a-select-option value="monthly">{{ i18n
"pages.inbounds.periodicTrafficReset.monthly"
}}</a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
@ -71,16 +88,20 @@
<template slot="label"> <template slot="label">
<a-tooltip> <a-tooltip>
<template slot="title"> <template slot="title">
<span>{{ i18n "pages.inbounds.leaveBlankToNeverExpire" }}</span> <span>{{ i18n "pages.inbounds.leaveBlankToNeverExpire"
}}</span>
</template> </template>
{{ i18n "pages.inbounds.expireDate" }} {{ i18n "pages.inbounds.expireDate" }}
<a-icon type="question-circle"></a-icon> <a-icon type="question-circle"></a-icon>
</a-tooltip> </a-tooltip>
</template> </template>
<a-date-picker :style="{ width: '100%' }" v-if="datepicker == 'gregorian'" :show-time="{ format: 'HH:mm:ss' }" <a-date-picker :style="{ width: '100%' }"
format="YYYY-MM-DD HH:mm:ss" :dropdown-class-name="themeSwitcher.currentTheme" v-if="datepicker == 'gregorian'" :show-time="{ format: 'HH:mm:ss' }"
format="YYYY-MM-DD HH:mm:ss"
:dropdown-class-name="themeSwitcher.currentTheme"
v-model="dbInbound._expiryTime"></a-date-picker> v-model="dbInbound._expiryTime"></a-date-picker>
<a-persian-datepicker v-else placeholder='{{ i18n "pages.settings.datepickerPlaceholder" }}' <a-persian-datepicker v-else
placeholder='{{ i18n "pages.settings.datepickerPlaceholder" }}'
value="dbInbound._expiryTime" v-model="dbInbound._expiryTime"> value="dbInbound._expiryTime" v-model="dbInbound._expiryTime">
</a-persian-datepicker> </a-persian-datepicker>
</a-form-item> </a-form-item>
@ -126,6 +147,11 @@
{{template "form/wireguard"}} {{template "form/wireguard"}}
</template> </template>
<!-- tun -->
<template v-if="inbound.protocol === Protocols.TUN">
{{template "form/tun"}}
</template>
<!-- stream settings --> <!-- stream settings -->
<template v-if="inbound.canEnableStream()"> <template v-if="inbound.canEnableStream()">
{{template "form/streamSettings"}} {{template "form/streamSettings"}}

View file

@ -1,12 +1,16 @@
{{define "form/outbound"}} {{define "form/outbound"}}
<!-- base --> <!-- base -->
<a-tabs :active-key="outModal.activeKey" :style="{ padding: '0', backgroundColor: 'transparent' }" <a-tabs :active-key="outModal.activeKey"
:style="{ padding: '0', backgroundColor: 'transparent' }"
@change="(activeKey) => {outModal.toggleJson(activeKey == '2'); }"> @change="(activeKey) => {outModal.toggleJson(activeKey == '2'); }">
<a-tab-pane key="1" tab="Form"> <a-tab-pane key="1" tab="Form">
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }"> <a-form :colon="false" :label-col="{ md: {span:8} }"
:wrapper-col="{ md: {span:14} }">
<a-form-item label='{{ i18n "protocol" }}'> <a-form-item label='{{ i18n "protocol" }}'>
<a-select v-model="outbound.protocol" :dropdown-class-name="themeSwitcher.currentTheme"> <a-select v-model="outbound.protocol"
<a-select-option v-for="x,y in Protocols" :value="x">[[ y ]]</a-select-option> :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="x,y in Protocols" :value="x">[[ y
]]</a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
<a-form-item label='{{ i18n "pages.xray.outbound.tag" }}' has-feedback <a-form-item label='{{ i18n "pages.xray.outbound.tag" }}' has-feedback
@ -21,8 +25,10 @@
<!-- freedom settings--> <!-- freedom settings-->
<template v-if="outbound.protocol === Protocols.Freedom"> <template v-if="outbound.protocol === Protocols.Freedom">
<a-form-item label='Strategy'> <a-form-item label='Strategy'>
<a-select v-model="outbound.settings.domainStrategy" :dropdown-class-name="themeSwitcher.currentTheme"> <a-select v-model="outbound.settings.domainStrategy"
<a-select-option v-for="s in OutboundDomainStrategies" :value="s">[[ s ]]</a-select-option> :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="s in OutboundDomainStrategies" :value="s">[[
s ]]</a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
<a-form-item label='Redirect'> <a-form-item label='Redirect'>
@ -35,18 +41,22 @@
</a-form-item> </a-form-item>
<template v-if="Object.keys(outbound.settings.fragment).length >0"> <template v-if="Object.keys(outbound.settings.fragment).length >0">
<a-form-item label='Packets'> <a-form-item label='Packets'>
<a-select v-model="outbound.settings.fragment.packets" :dropdown-class-name="themeSwitcher.currentTheme"> <a-select v-model="outbound.settings.fragment.packets"
<a-select-option v-for="s in ['1-3','tlshello']" :value="s">[[ s ]]</a-select-option> :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="s in ['1-3','tlshello']" :value="s">[[ s
]]</a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
<a-form-item label='Length'> <a-form-item label='Length'>
<a-input v-model.trim="outbound.settings.fragment.length"></a-input> <a-input v-model.trim="outbound.settings.fragment.length"></a-input>
</a-form-item> </a-form-item>
<a-form-item label='Interval'> <a-form-item label='Interval'>
<a-input v-model.trim="outbound.settings.fragment.interval"></a-input> <a-input
v-model.trim="outbound.settings.fragment.interval"></a-input>
</a-form-item> </a-form-item>
<a-form-item label='Max Split'> <a-form-item label='Max Split'>
<a-input v-model.trim="outbound.settings.fragment.maxSplit"></a-input> <a-input
v-model.trim="outbound.settings.fragment.maxSplit"></a-input>
</a-form-item> </a-form-item>
</template> </template>
@ -60,11 +70,13 @@
<!-- Add Noise Button --> <!-- Add Noise Button -->
<template v-if="outbound.settings.noises.length > 0"> <template v-if="outbound.settings.noises.length > 0">
<a-form-item label="Noises"> <a-form-item label="Noises">
<a-button icon="plus" type="primary" size="small" @click="outbound.settings.addNoise()"></a-button> <a-button icon="plus" type="primary" size="small"
@click="outbound.settings.addNoise()"></a-button>
</a-form-item> </a-form-item>
<!-- Noise Configurations --> <!-- Noise Configurations -->
<a-form v-for="(noise, index) in outbound.settings.noises" :key="index" :colon="false" <a-form v-for="(noise, index) in outbound.settings.noises"
:key="index" :colon="false"
:label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }"> :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
<a-divider :style="{ margin: '0' }"> Noise [[ index + 1 ]] <a-divider :style="{ margin: '0' }"> Noise [[ index + 1 ]]
<a-icon v-if="outbound.settings.noises.length > 1" type="delete" <a-icon v-if="outbound.settings.noises.length > 1" type="delete"
@ -72,8 +84,10 @@
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"></a-icon> :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"></a-icon>
</a-divider> </a-divider>
<a-form-item label='Type'> <a-form-item label='Type'>
<a-select v-model="noise.type" :dropdown-class-name="themeSwitcher.currentTheme"> <a-select v-model="noise.type"
<a-select-option v-for="s in ['rand','base64','str', 'hex']" :value="s">[[ s ]]</a-select-option> :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="s in ['rand','base64','str', 'hex']"
:value="s">[[ s ]]</a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
<a-form-item label='Packet'> <a-form-item label='Packet'>
@ -83,8 +97,10 @@
<a-input v-model.trim="noise.delay"></a-input> <a-input v-model.trim="noise.delay"></a-input>
</a-form-item> </a-form-item>
<a-form-item label='Apply To'> <a-form-item label='Apply To'>
<a-select v-model="noise.applyTo" :dropdown-class-name="themeSwitcher.currentTheme"> <a-select v-model="noise.applyTo"
<a-select-option v-for="s in ['ip','ipv4','ipv6']" :value="s">[[ s ]]</a-select-option> :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="s in ['ip','ipv4','ipv6']" :value="s">[[
s ]]</a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
</a-form> </a-form>
@ -94,8 +110,10 @@
<!-- blackhole settings --> <!-- blackhole settings -->
<template v-if="outbound.protocol === Protocols.Blackhole"> <template v-if="outbound.protocol === Protocols.Blackhole">
<a-form-item label='Response Type'> <a-form-item label='Response Type'>
<a-select v-model="outbound.settings.type" :dropdown-class-name="themeSwitcher.currentTheme"> <a-select v-model="outbound.settings.type"
<a-select-option v-for="s in ['', 'none','http']" :value="s">[[ s ]]</a-select-option> :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="s in ['', 'none','http']" :value="s">[[ s
]]</a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
</template> </template>
@ -103,16 +121,21 @@
<!-- dns settings --> <!-- dns settings -->
<template v-if="outbound.protocol === Protocols.DNS"> <template v-if="outbound.protocol === Protocols.DNS">
<a-form-item label='{{ i18n "pages.inbounds.network" }}'> <a-form-item label='{{ i18n "pages.inbounds.network" }}'>
<a-select v-model="outbound.settings.network" :dropdown-class-name="themeSwitcher.currentTheme"> <a-select v-model="outbound.settings.network"
<a-select-option v-for="s in ['udp','tcp']" :value="s">[[ s ]]</a-select-option> :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="s in ['udp','tcp']" :value="s">[[ s
]]</a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
<a-form-item label='non-IP queries'> <a-form-item label='non-IP queries'>
<a-select v-model="outbound.settings.nonIPQuery" :dropdown-class-name="themeSwitcher.currentTheme"> <a-select v-model="outbound.settings.nonIPQuery"
<a-select-option v-for="s in ['reject','drop','skip']" :value="s">[[ s ]]</a-select-option> :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="s in ['reject','drop','skip']" :value="s">[[
s ]]</a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
<a-form-item v-if="outbound.settings.nonIPQuery === 'skip'" label='Block Types'> <a-form-item v-if="outbound.settings.nonIPQuery === 'skip'"
label='Block Types'>
<a-input v-model.number="outbound.settings.blockTypes"></a-input> <a-input v-model.number="outbound.settings.blockTypes"></a-input>
</a-form-item> </a-form-item>
</template> </template>
@ -149,15 +172,19 @@
<a-input disabled v-model="outbound.settings.pubKey"></a-input> <a-input disabled v-model="outbound.settings.pubKey"></a-input>
</a-form-item> </a-form-item>
<a-form-item label='{{ i18n "pages.xray.wireguard.domainStrategy" }}'> <a-form-item label='{{ i18n "pages.xray.wireguard.domainStrategy" }}'>
<a-select v-model="outbound.settings.domainStrategy" :dropdown-class-name="themeSwitcher.currentTheme"> <a-select v-model="outbound.settings.domainStrategy"
<a-select-option v-for="wds in ['', ...WireguardDomainStrategy]" :value="wds">[[ wds ]]</a-select-option> :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="wds in ['', ...WireguardDomainStrategy]"
:value="wds">[[ wds ]]</a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
<a-form-item label='MTU'> <a-form-item label='MTU'>
<a-input-number v-model.number="outbound.settings.mtu" min="0"></a-input-number> <a-input-number v-model.number="outbound.settings.mtu"
min="0"></a-input-number>
</a-form-item> </a-form-item>
<a-form-item label='Workers'> <a-form-item label='Workers'>
<a-input-number v-model.number="outbound.settings.workers" min="0"></a-input-number> <a-input-number v-model.number="outbound.settings.workers"
min="0"></a-input-number>
</a-form-item> </a-form-item>
<a-form-item label='No Kernel Tun'> <a-form-item label='No Kernel Tun'>
<a-switch v-model="outbound.settings.noKernelTun"></a-switch> <a-switch v-model="outbound.settings.noKernelTun"></a-switch>
@ -173,11 +200,14 @@
<a-input v-model="outbound.settings.reserved"></a-input> <a-input v-model="outbound.settings.reserved"></a-input>
</a-form-item> </a-form-item>
<a-form-item label="Peers"> <a-form-item label="Peers">
<a-button icon="plus" type="primary" size="small" @click="outbound.settings.addPeer()"></a-button> <a-button icon="plus" type="primary" size="small"
@click="outbound.settings.addPeer()"></a-button>
</a-form-item> </a-form-item>
<a-form v-for="(peer, index) in outbound.settings.peers" :colon="false" :label-col="{ md: {span:8} }" <a-form v-for="(peer, index) in outbound.settings.peers" :colon="false"
:label-col="{ md: {span:8} }"
:wrapper-col="{ md: {span:14} }"> :wrapper-col="{ md: {span:14} }">
<a-divider :style="{ margin: '0' }"> Peer [[ index + 1 ]] <a-icon v-if="outbound.settings.peers.length>1" <a-divider :style="{ margin: '0' }"> Peer [[ index + 1 ]] <a-icon
v-if="outbound.settings.peers.length>1"
type="delete" @click="() => outbound.settings.delPeer(index)" type="delete" @click="() => outbound.settings.delPeer(index)"
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"></a-icon> :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"></a-icon>
</a-divider> </a-divider>
@ -193,17 +223,21 @@
<a-form-item> <a-form-item>
<template slot="label"> <template slot="label">
{{ i18n "pages.xray.wireguard.allowedIPs" }} {{ i18n "pages.xray.wireguard.allowedIPs" }}
<a-button icon="plus" type="primary" size="small" @click="peer.allowedIPs.push('')"></a-button> <a-button icon="plus" type="primary" size="small"
@click="peer.allowedIPs.push('')"></a-button>
</template> </template>
<template v-for="(aip, index) in peer.allowedIPs" :style="{ marginBottom: '10px' }"> <template v-for="(aip, index) in peer.allowedIPs"
:style="{ marginBottom: '10px' }">
<a-input v-model.trim="peer.allowedIPs[index]"> <a-input v-model.trim="peer.allowedIPs[index]">
<a-button icon="minus" v-if="peer.allowedIPs.length>1" slot="addonAfter" size="small" <a-button icon="minus" v-if="peer.allowedIPs.length>1"
slot="addonAfter" size="small"
@click="peer.allowedIPs.splice(index, 1)"></a-button> @click="peer.allowedIPs.splice(index, 1)"></a-button>
</a-input> </a-input>
</template> </template>
</a-form-item> </a-form-item>
<a-form-item label='Keep Alive'> <a-form-item label='Keep Alive'>
<a-input-number v-model.number="peer.keepAlive" :min="0"></a-input-number> <a-input-number v-model.number="peer.keepAlive"
:min="0"></a-input-number>
</a-form-item> </a-form-item>
</a-form> </a-form>
</template> </template>
@ -214,12 +248,14 @@
<a-input v-model.trim="outbound.settings.address"></a-input> <a-input v-model.trim="outbound.settings.address"></a-input>
</a-form-item> </a-form-item>
<a-form-item label='{{ i18n "pages.inbounds.port" }}'> <a-form-item label='{{ i18n "pages.inbounds.port" }}'>
<a-input-number v-model.number="outbound.settings.port" :min="1" :max="65532"></a-input-number> <a-input-number v-model.number="outbound.settings.port" :min="1"
:max="65532"></a-input-number>
</a-form-item> </a-form-item>
</template> </template>
<!-- VLESS/VMess user settings --> <!-- VLESS/VMess user settings -->
<template v-if="[Protocols.VMess, Protocols.VLESS].includes(outbound.protocol)"> <template
v-if="[Protocols.VMess, Protocols.VLESS].includes(outbound.protocol)">
<a-form-item label='ID'> <a-form-item label='ID'>
<a-input v-model.trim="outbound.settings.id"></a-input> <a-input v-model.trim="outbound.settings.id"></a-input>
</a-form-item> </a-form-item>
@ -227,8 +263,10 @@
<!-- vmess settings --> <!-- vmess settings -->
<template v-if="outbound.protocol === Protocols.VMess"> <template v-if="outbound.protocol === Protocols.VMess">
<a-form-item label='Security'> <a-form-item label='Security'>
<a-select v-model="outbound.settings.security" :dropdown-class-name="themeSwitcher.currentTheme"> <a-select v-model="outbound.settings.security"
<a-select-option v-for="key in USERS_SECURITY" :value="key">[[ key ]]</a-select-option> :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="key in USERS_SECURITY" :value="key">[[ key
]]</a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
</template> </template>
@ -241,35 +279,47 @@
</template> </template>
<template v-if="outbound.canEnableTlsFlow()"> <template v-if="outbound.canEnableTlsFlow()">
<a-form-item label='Flow'> <a-form-item label='Flow'>
<a-select v-model="outbound.settings.flow" :dropdown-class-name="themeSwitcher.currentTheme"> <a-select v-model="outbound.settings.flow"
<a-select-option value="" selected>{{ i18n "none" }}</a-select-option> :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="key in TLS_FLOW_CONTROL" :value="key">[[ key ]]</a-select-option> <a-select-option value selected>{{ i18n "none"
}}</a-select-option>
<a-select-option v-for="key in TLS_FLOW_CONTROL" :value="key">[[
key ]]</a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
</template> </template>
<!-- XTLS Vision Advanced Settings --> <!-- XTLS Vision Advanced Settings -->
<template v-if="outbound.canEnableVisionSeed()"> <template v-if="outbound.canEnableVisionSeed()">
<a-form-item label="Vision Pre-Connect"> <a-form-item label="Vision Pre-Connect">
<a-input-number v-model.number="outbound.settings.testpre" :min="0" :max="10" :style="{ width: '100%' }" <a-input-number v-model.number="outbound.settings.testpre" :min="0"
:max="10" :style="{ width: '100%' }"
placeholder="0"></a-input-number> placeholder="0"></a-input-number>
</a-form-item> </a-form-item>
<a-form-item label="Vision Seed"> <a-form-item label="Vision Seed">
<a-row :gutter="8"> <a-row :gutter="8">
<a-col :span="6"> <a-col :span="6">
<a-input-number v-model.number="outbound.settings.testseed[0]" :min="0" :max="9999" <a-input-number v-model.number="outbound.settings.testseed[0]"
:style="{ width: '100%' }" placeholder="900" addon-before="[0]"></a-input-number> :min="0" :max="9999"
:style="{ width: '100%' }" placeholder="900"
addon-before="[0]"></a-input-number>
</a-col> </a-col>
<a-col :span="6"> <a-col :span="6">
<a-input-number v-model.number="outbound.settings.testseed[1]" :min="0" :max="9999" <a-input-number v-model.number="outbound.settings.testseed[1]"
:style="{ width: '100%' }" placeholder="500" addon-before="[1]"></a-input-number> :min="0" :max="9999"
:style="{ width: '100%' }" placeholder="500"
addon-before="[1]"></a-input-number>
</a-col> </a-col>
<a-col :span="6"> <a-col :span="6">
<a-input-number v-model.number="outbound.settings.testseed[2]" :min="0" :max="9999" <a-input-number v-model.number="outbound.settings.testseed[2]"
:style="{ width: '100%' }" placeholder="900" addon-before="[2]"></a-input-number> :min="0" :max="9999"
:style="{ width: '100%' }" placeholder="900"
addon-before="[2]"></a-input-number>
</a-col> </a-col>
<a-col :span="6"> <a-col :span="6">
<a-input-number v-model.number="outbound.settings.testseed[3]" :min="0" :max="9999" <a-input-number v-model.number="outbound.settings.testseed[3]"
:style="{ width: '100%' }" placeholder="256" addon-before="[3]"></a-input-number> :min="0" :max="9999"
:style="{ width: '100%' }" placeholder="256"
addon-before="[3]"></a-input-number>
</a-col> </a-col>
</a-row> </a-row>
</a-form-item> </a-form-item>
@ -289,7 +339,8 @@
</template> </template>
<!-- trojan/shadowsocks --> <!-- trojan/shadowsocks -->
<template v-if="[Protocols.Trojan, Protocols.Shadowsocks].includes(outbound.protocol)"> <template
v-if="[Protocols.Trojan, Protocols.Shadowsocks].includes(outbound.protocol)">
<a-form-item label='{{ i18n "password" }}'> <a-form-item label='{{ i18n "password" }}'>
<a-input v-model.trim="outbound.settings.password"></a-input> <a-input v-model.trim="outbound.settings.password"></a-input>
</a-form-item> </a-form-item>
@ -298,8 +349,10 @@
<!-- shadowsocks --> <!-- shadowsocks -->
<template v-if="outbound.protocol === Protocols.Shadowsocks"> <template v-if="outbound.protocol === Protocols.Shadowsocks">
<a-form-item label='{{ i18n "encryption" }}'> <a-form-item label='{{ i18n "encryption" }}'>
<a-select v-model="outbound.settings.method" :dropdown-class-name="themeSwitcher.currentTheme"> <a-select v-model="outbound.settings.method"
<a-select-option v-for="(method, method_name) in SSMethods" :value="method">[[ method_name :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="(method, method_name) in SSMethods"
:value="method">[[ method_name
]]</a-select-option> ]]</a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
@ -307,15 +360,25 @@
<a-switch v-model="outbound.settings.uot"></a-switch> <a-switch v-model="outbound.settings.uot"></a-switch>
</a-form-item> </a-form-item>
<a-form-item label='UoTVersion'> <a-form-item label='UoTVersion'>
<a-input-number v-model.number="outbound.settings.UoTVersion" :min="1" :max="2"></a-input-number> <a-input-number v-model.number="outbound.settings.UoTVersion"
:min="1" :max="2"></a-input-number>
</a-form-item> </a-form-item>
</template> </template>
</template> </template>
<!-- hysteria settings -->
<template v-if="outbound.protocol === Protocols.Hysteria">
<a-form-item label='Version'>
<a-input-number v-model.number="outbound.settings.version" :min="2"
:max="2" disabled></a-input-number>
</a-form-item>
</template>
<!-- stream settings --> <!-- stream settings -->
<template v-if="outbound.canEnableStream()"> <template v-if="outbound.canEnableStream()">
<a-form-item label='{{ i18n "transmission" }}'> <a-form-item label='{{ i18n "transmission" }}'>
<a-select v-model="outbound.stream.network" @change="streamNetworkChange" <a-select v-model="outbound.stream.network"
@change="streamNetworkChange"
:dropdown-class-name="themeSwitcher.currentTheme"> :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value="tcp">TCP (RAW)</a-select-option> <a-select-option value="tcp">TCP (RAW)</a-select-option>
<a-select-option value="kcp">mKCP</a-select-option> <a-select-option value="kcp">mKCP</a-select-option>
@ -323,6 +386,8 @@
<a-select-option value="grpc">gRPC</a-select-option> <a-select-option value="grpc">gRPC</a-select-option>
<a-select-option value="httpupgrade">HTTPUpgrade</a-select-option> <a-select-option value="httpupgrade">HTTPUpgrade</a-select-option>
<a-select-option value="xhttp">XHTTP</a-select-option> <a-select-option value="xhttp">XHTTP</a-select-option>
<a-select-option v-if="outbound.protocol === Protocols.Hysteria"
value="hysteria">Hysteria2</a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
<template v-if="outbound.stream.network === 'tcp'"> <template v-if="outbound.stream.network === 'tcp'">
@ -342,40 +407,32 @@
<!-- kcp --> <!-- kcp -->
<template v-if="outbound.stream.network === 'kcp'"> <template v-if="outbound.stream.network === 'kcp'">
<a-form-item label='{{ i18n "camouflage" }}'>
<a-select v-model="outbound.stream.kcp.type" :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value="none">None</a-select-option>
<a-select-option value="srtp">SRTP</a-select-option>
<a-select-option value="utp">uTP</a-select-option>
<a-select-option value="wechat-video">WeChat</a-select-option>
<a-select-option value="dtls">DTLS 1.2</a-select-option>
<a-select-option value="wireguard">WireGuard</a-select-option>
<a-select-option value="dns">DNS</a-select-option>
</a-select>
</a-form-item>
<a-form-item label='{{ i18n "password" }}'>
<a-input v-model="outbound.stream.kcp.seed"></a-input>
</a-form-item>
<a-form-item label='MTU'> <a-form-item label='MTU'>
<a-input-number v-model.number="outbound.stream.kcp.mtu" min="0"></a-input-number> <a-input-number v-model.number="outbound.stream.kcp.mtu"
min="0"></a-input-number>
</a-form-item> </a-form-item>
<a-form-item label='TTI (ms)'> <a-form-item label='TTI (ms)'>
<a-input-number v-model.number="outbound.stream.kcp.tti" min="0"></a-input-number> <a-input-number v-model.number="outbound.stream.kcp.tti"
min="0"></a-input-number>
</a-form-item> </a-form-item>
<a-form-item label='Uplink (MB/s)'> <a-form-item label='Uplink (MB/s)'>
<a-input-number v-model.number="outbound.stream.kcp.upCap" min="0"></a-input-number> <a-input-number v-model.number="outbound.stream.kcp.upCap"
min="0"></a-input-number>
</a-form-item> </a-form-item>
<a-form-item label='Downlink (MB/s)'> <a-form-item label='Downlink (MB/s)'>
<a-input-number v-model.number="outbound.stream.kcp.downCap" min="0"></a-input-number> <a-input-number v-model.number="outbound.stream.kcp.downCap"
min="0"></a-input-number>
</a-form-item> </a-form-item>
<a-form-item label='Congestion'> <a-form-item label='Congestion'>
<a-switch v-model="outbound.stream.kcp.congestion"></a-switch> <a-switch v-model="outbound.stream.kcp.congestion"></a-switch>
</a-form-item> </a-form-item>
<a-form-item label='Read Buffer (MB)'> <a-form-item label='Read Buffer (MB)'>
<a-input-number v-model.number="outbound.stream.kcp.readBuffer" min="0"></a-input-number> <a-input-number v-model.number="outbound.stream.kcp.readBuffer"
min="0"></a-input-number>
</a-form-item> </a-form-item>
<a-form-item label='Write Buffer (MB)'> <a-form-item label='Write Buffer (MB)'>
<a-input-number v-model.number="outbound.stream.kcp.writeBuffer" min="0"></a-input-number> <a-input-number v-model.number="outbound.stream.kcp.writeBuffer"
min="0"></a-input-number>
</a-form-item> </a-form-item>
</template> </template>
@ -388,7 +445,8 @@
<a-input v-model.trim="outbound.stream.ws.path"></a-input> <a-input v-model.trim="outbound.stream.ws.path"></a-input>
</a-form-item> </a-form-item>
<a-form-item label='Heartbeat Period'> <a-form-item label='Heartbeat Period'>
<a-input-number v-model.number="outbound.stream.ws.heartbeatPeriod" :min="0"></a-input-number> <a-input-number v-model.number="outbound.stream.ws.heartbeatPeriod"
:min="0"></a-input-number>
</a-form-item> </a-form-item>
</template> </template>
@ -424,45 +482,199 @@
<a-input v-model.trim="outbound.stream.xhttp.path"></a-input> <a-input v-model.trim="outbound.stream.xhttp.path"></a-input>
</a-form-item> </a-form-item>
<a-form-item label='Mode'> <a-form-item label='Mode'>
<a-select v-model="outbound.stream.xhttp.mode" :dropdown-class-name="themeSwitcher.currentTheme"> <a-select v-model="outbound.stream.xhttp.mode"
<a-select-option v-for="key in MODE_OPTION" :value="key">[[ key ]]</a-select-option> :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="key in MODE_OPTION" :value="key">[[ key
]]</a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
<a-form-item label="No gRPC Header" <a-form-item label="No gRPC Header"
v-if="outbound.stream.xhttp.mode === 'stream-up' || outbound.stream.xhttp.mode === 'stream-one'"> v-if="outbound.stream.xhttp.mode === 'stream-up' || outbound.stream.xhttp.mode === 'stream-one'">
<a-switch v-model="outbound.stream.xhttp.noGRPCHeader"></a-switch> <a-switch v-model="outbound.stream.xhttp.noGRPCHeader"></a-switch>
</a-form-item> </a-form-item>
<a-form-item label="Min Upload Interval (Ms)" v-if="outbound.stream.xhttp.mode === 'packet-up'"> <a-form-item label="Min Upload Interval (Ms)"
<a-input v-model.trim="outbound.stream.xhttp.scMinPostsIntervalMs"></a-input> v-if="outbound.stream.xhttp.mode === 'packet-up'">
<a-input
v-model.trim="outbound.stream.xhttp.scMinPostsIntervalMs"></a-input>
</a-form-item> </a-form-item>
<a-form-item label="Max Concurrency" v-if="!outbound.stream.xhttp.xmux.maxConnections"> <a-form-item label="Max Concurrency"
<a-input v-model="outbound.stream.xhttp.xmux.maxConcurrency"></a-input> v-if="!outbound.stream.xhttp.xmux.maxConnections">
<a-input
v-model="outbound.stream.xhttp.xmux.maxConcurrency"></a-input>
</a-form-item> </a-form-item>
<a-form-item label="Max Connections" v-if="!outbound.stream.xhttp.xmux.maxConcurrency"> <a-form-item label="Max Connections"
<a-input v-model="outbound.stream.xhttp.xmux.maxConnections"></a-input> v-if="!outbound.stream.xhttp.xmux.maxConcurrency">
<a-input
v-model="outbound.stream.xhttp.xmux.maxConnections"></a-input>
</a-form-item> </a-form-item>
<a-form-item label="Max Reuse Times"> <a-form-item label="Max Reuse Times">
<a-input v-model="outbound.stream.xhttp.xmux.cMaxReuseTimes"></a-input> <a-input
v-model="outbound.stream.xhttp.xmux.cMaxReuseTimes"></a-input>
</a-form-item> </a-form-item>
<a-form-item label="Max Request Times"> <a-form-item label="Max Request Times">
<a-input v-model="outbound.stream.xhttp.xmux.hMaxRequestTimes"></a-input> <a-input
v-model="outbound.stream.xhttp.xmux.hMaxRequestTimes"></a-input>
</a-form-item> </a-form-item>
<a-form-item label="Max Reusable Secs"> <a-form-item label="Max Reusable Secs">
<a-input v-model="outbound.stream.xhttp.xmux.hMaxReusableSecs"></a-input> <a-input
v-model="outbound.stream.xhttp.xmux.hMaxReusableSecs"></a-input>
</a-form-item> </a-form-item>
<a-form-item label='Keep Alive Period'> <a-form-item label='Keep Alive Period'>
<a-input-number v-model.number="outbound.stream.xhttp.xmux.hKeepAlivePeriod"></a-input-number> <a-input-number
v-model.number="outbound.stream.xhttp.xmux.hKeepAlivePeriod"></a-input-number>
</a-form-item> </a-form-item>
</template> </template>
<!-- hysteria -->
<template v-if="outbound.stream.network === 'hysteria'">
<a-form-item label='Auth Password'>
<a-input v-model.trim="outbound.stream.hysteria.auth"></a-input>
</a-form-item>
<a-form-item label='Congestion'>
<a-select v-model="outbound.stream.hysteria.congestion"
:dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value>BBR (Auto)</a-select-option>
<a-select-option value="brutal">Brutal</a-select-option>
</a-select>
</a-form-item>
<a-form-item label='Upload Speed'>
<a-input v-model.trim="outbound.stream.hysteria.up"
placeholder="0 (BBR mode), e.g., 100 mbps"></a-input>
</a-form-item>
<a-form-item label='Download Speed'>
<a-input v-model.trim="outbound.stream.hysteria.down"
placeholder="0 (BBR mode), e.g., 100 mbps"></a-input>
</a-form-item>
<a-form-item label='UDP Hop Port'>
<a-input v-model.trim="outbound.stream.hysteria.udphopPort"
placeholder="e.g., 1145-1919 or 11,13,15-17"></a-input>
</a-form-item>
<a-form-item label='UDP Hop Interval Min (s)'
v-if="outbound.stream.hysteria.udphopPort">
<a-input-number
v-model.number="outbound.stream.hysteria.udphopIntervalMin"
:min="5"></a-input-number>
</a-form-item>
<a-form-item label='UDP Hop Interval Max (s)'
v-if="outbound.stream.hysteria.udphopPort">
<a-input-number
v-model.number="outbound.stream.hysteria.udphopIntervalMax"
:min="5"></a-input-number>
</a-form-item>
<a-form-item label='Init Stream Receive'>
<a-input-number
v-model.number="outbound.stream.hysteria.initStreamReceiveWindow"></a-input-number>
</a-form-item>
<a-form-item label='Max Stream Receive'>
<a-input-number
v-model.number="outbound.stream.hysteria.maxStreamReceiveWindow"></a-input-number>
</a-form-item>
<a-form-item label='Init Connection Receive'>
<a-input-number
v-model.number="outbound.stream.hysteria.initConnectionReceiveWindow"></a-input-number>
</a-form-item>
<a-form-item label='Max Connection Receive'>
<a-input-number
v-model.number="outbound.stream.hysteria.maxConnectionReceiveWindow"></a-input-number>
</a-form-item>
<a-form-item label='Max Idle Timeout (s)'>
<a-input-number
v-model.number="outbound.stream.hysteria.maxIdleTimeout" :min="4"
:max="120"></a-input-number>
</a-form-item>
<a-form-item label='Keep Alive Period (s)'>
<a-input-number
v-model.number="outbound.stream.hysteria.keepAlivePeriod" :min="0"
:max="60"></a-input-number>
</a-form-item>
<a-form-item label='Disable Path MTU'>
<a-switch
v-model="outbound.stream.hysteria.disablePathMTUDiscovery"></a-switch>
</a-form-item>
</template>
</template>
<!-- finalmask settings -->
<template v-if="outbound.canEnableStream()">
<a-form-item label="UDP Masks">
<a-button icon="plus" type="primary" size="small"
@click="outbound.stream.addUdpMask(outbound.protocol === Protocols.Hysteria ? 'salamander' : (outbound.stream.network === 'kcp' ? 'mkcp-aes128gcm' : 'xdns'))"></a-button>
</a-form-item>
<template
v-if="outbound.stream.finalmask.udp && outbound.stream.finalmask.udp.length > 0">
<a-form v-for="(mask, index) in outbound.stream.finalmask.udp"
:key="index" :colon="false"
:label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
<a-divider :style="{ margin: '0' }"> UDP Mask [[ index + 1 ]]
<a-icon type="delete"
@click="() => outbound.stream.delUdpMask(index)"
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"></a-icon>
</a-divider>
<a-form-item label='Type'>
<a-select v-model="mask.type"
@change="(type) => mask.settings = mask._getDefaultSettings(type, {})"
:dropdown-class-name="themeSwitcher.currentTheme">
<!-- Salamander for Hysteria2 only -->
<a-select-option v-if="outbound.protocol === Protocols.Hysteria"
value="salamander">
Salamander (Hysteria2)</a-select-option>
<!-- mKCP-specific masks -->
<a-select-option v-if="outbound.stream.network === 'kcp'"
value="mkcp-aes128gcm">
mKCP AES-128-GCM</a-select-option>
<a-select-option v-if="outbound.stream.network === 'kcp'"
value="header-dns">
Header DNS</a-select-option>
<a-select-option v-if="outbound.stream.network === 'kcp'"
value="header-dtls">
Header DTLS 1.2</a-select-option>
<a-select-option v-if="outbound.stream.network === 'kcp'"
value="header-srtp">
Header SRTP</a-select-option>
<a-select-option v-if="outbound.stream.network === 'kcp'"
value="header-utp">
Header uTP</a-select-option>
<a-select-option v-if="outbound.stream.network === 'kcp'"
value="header-wechat">
Header WeChat Video</a-select-option>
<a-select-option v-if="outbound.stream.network === 'kcp'"
value="header-wireguard">
Header WireGuard</a-select-option>
<a-select-option v-if="outbound.stream.network === 'kcp'"
value="mkcp-original">
mKCP Original</a-select-option>
<!-- xDNS for TCP/WS/HTTPUpgrade/XHTTP -->
<a-select-option
v-if="['tcp', 'ws', 'httpupgrade', 'xhttp'].includes(outbound.stream.network)"
value="xdns">
xDNS (Experimental)</a-select-option>
</a-select>
</a-form-item>
<!-- Settings for password-based masks -->
<a-form-item label='Password'
v-if="['salamander', 'mkcp-aes128gcm'].includes(mask.type)">
<a-input v-model.trim="mask.settings.password"
placeholder="Obfuscation password"></a-input>
</a-form-item>
<!-- Settings for domain-based masks -->
<a-form-item label='Domain'
v-if="['header-dns', 'xdns'].includes(mask.type)">
<a-input v-model.trim="mask.settings.domain"
placeholder="e.g., www.example.com"></a-input>
</a-form-item>
</a-form>
</template>
</template> </template>
<!-- tls settings --> <!-- tls settings -->
<template v-if="outbound.canEnableTls()"> <template v-if="outbound.canEnableTls()">
<a-form-item label='{{ i18n "security" }}'> <a-form-item label='{{ i18n "security" }}'>
<a-radio-group v-model="outbound.stream.security" button-style="solid"> <a-radio-group v-model="outbound.stream.security"
button-style="solid">
<a-radio-button value="none">{{ i18n "none" }}</a-radio-button> <a-radio-button value="none">{{ i18n "none" }}</a-radio-button>
<a-radio-button value="tls">TLS</a-radio-button> <a-radio-button value="tls">TLS</a-radio-button>
<a-radio-button v-if="outbound.canEnableReality()" value="reality">Reality</a-radio-button> <a-radio-button v-if="outbound.canEnableReality()"
value="reality">Reality</a-radio-button>
</a-radio-group> </a-radio-group>
</a-form-item> </a-form-item>
<template v-if="outbound.stream.isTls"> <template v-if="outbound.stream.isTls">
@ -470,33 +682,47 @@
<a-input v-model.trim="outbound.stream.tls.serverName"></a-input> <a-input v-model.trim="outbound.stream.tls.serverName"></a-input>
</a-form-item> </a-form-item>
<a-form-item label="uTLS"> <a-form-item label="uTLS">
<a-select v-model="outbound.stream.tls.fingerprint" :dropdown-class-name="themeSwitcher.currentTheme"> <a-select v-model="outbound.stream.tls.fingerprint"
<a-select-option value=''>None</a-select-option> :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="key in UTLS_FINGERPRINT" :value="key">[[ key ]]</a-select-option> <a-select-option value>None</a-select-option>
<a-select-option v-for="key in UTLS_FINGERPRINT" :value="key">[[
key ]]</a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
<a-form-item label="ALPN"> <a-form-item label="ALPN">
<a-select mode="multiple" :dropdown-class-name="themeSwitcher.currentTheme" <a-select mode="multiple"
:dropdown-class-name="themeSwitcher.currentTheme"
v-model="outbound.stream.tls.alpn"> v-model="outbound.stream.tls.alpn">
<a-select-option v-for="alpn in ALPN_OPTION" :value="alpn">[[ alpn ]]</a-select-option> <a-select-option v-for="alpn in ALPN_OPTION" :value="alpn">[[ alpn
]]</a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
<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-form-item label="verify Peer Cert By Name">
<a-switch v-model="outbound.stream.tls.allowInsecure"></a-switch> <a-input
v-model.trim="outbound.stream.tls.verifyPeerCertByName"
placeholder="cloudflare-dns.com"></a-input>
</a-form-item>
<a-form-item label=" pinned Peer Cert Sha256">
<a-input v-model.trim="outbound.stream.tls.pinnedPeerCertSha256"
placeholder="Enter SHA256 fingerprints (base64)">
</a-input>
</a-form-item> </a-form-item>
</template> </template>
<!-- reality settings --> <!-- reality settings -->
<template v-if="outbound.stream.isReality"> <template v-if="outbound.stream.isReality">
<a-form-item label="SNI"> <a-form-item label="SNI">
<a-input v-model.trim="outbound.stream.reality.serverName"></a-input> <a-input
v-model.trim="outbound.stream.reality.serverName"></a-input>
</a-form-item> </a-form-item>
<a-form-item label="uTLS"> <a-form-item label="uTLS">
<a-select v-model="outbound.stream.reality.fingerprint" :dropdown-class-name="themeSwitcher.currentTheme"> <a-select v-model="outbound.stream.reality.fingerprint"
<a-select-option v-for="key in UTLS_FINGERPRINT" :value="key">[[ key ]]</a-select-option> :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="key in UTLS_FINGERPRINT" :value="key">[[
key ]]</a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
<a-form-item label="Short ID"> <a-form-item label="Short ID">
@ -506,10 +732,12 @@
<a-input v-model.trim="outbound.stream.reality.spiderX"></a-input> <a-input v-model.trim="outbound.stream.reality.spiderX"></a-input>
</a-form-item> </a-form-item>
<a-form-item label="Public Key"> <a-form-item label="Public Key">
<a-textarea v-model.trim="outbound.stream.reality.publicKey"></a-textarea> <a-textarea
v-model.trim="outbound.stream.reality.publicKey"></a-textarea>
</a-form-item> </a-form-item>
<a-form-item label="mldsa65 Verify"> <a-form-item label="mldsa65 Verify">
<a-textarea v-model.trim="outbound.stream.reality.mldsa65Verify"></a-textarea> <a-textarea
v-model.trim="outbound.stream.reality.mldsa65Verify"></a-textarea>
</a-form-item> </a-form-item>
</template> </template>
</template> </template>
@ -520,34 +748,44 @@
</a-form-item> </a-form-item>
<template v-if="outbound.stream.sockoptSwitch"> <template v-if="outbound.stream.sockoptSwitch">
<a-form-item label="Dialer Proxy"> <a-form-item label="Dialer Proxy">
<a-select v-model="outbound.stream.sockopt.dialerProxy" :dropdown-class-name="themeSwitcher.currentTheme"> <a-select v-model="outbound.stream.sockopt.dialerProxy"
<a-select-option v-for="tag in ['', ...outModal.tags]" :value="tag">[[ tag ]]</a-select-option> :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="tag in ['', ...outModal.tags]"
:value="tag">[[ tag ]]</a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
<a-form-item label='Address Port Strategy'> <a-form-item label='Address Port Strategy'>
<a-select v-model="outbound.stream.sockopt.addressPortStrategy" <a-select v-model="outbound.stream.sockopt.addressPortStrategy"
:dropdown-class-name="themeSwitcher.currentTheme"> :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="key in Address_Port_Strategy" :value="key">[[ key ]]</a-select-option> <a-select-option v-for="key in Address_Port_Strategy"
:value="key">[[ key ]]</a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
<a-form-item label="Keep Alive Interval"> <a-form-item label="Keep Alive Interval">
<a-input-number v-model.number="outbound.stream.sockopt.tcpKeepAliveInterval" :min="0"></a-input-number> <a-input-number
v-model.number="outbound.stream.sockopt.tcpKeepAliveInterval"
:min="0"></a-input-number>
</a-form-item> </a-form-item>
<a-form-item label="TCP Fast Open"> <a-form-item label="TCP Fast Open">
<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>
</a-form-item> </a-form-item>
<a-form-item label="Trusted X-Forwarded-For"> <a-form-item label="Trusted X-Forwarded-For">
<a-select mode="tags" v-model="outbound.stream.sockopt.trustedXForwardedFor" :style="{ width: '100%' }" <a-select mode="tags"
v-model="outbound.stream.sockopt.trustedXForwardedFor"
:style="{ width: '100%' }"
:dropdown-class-name="themeSwitcher.currentTheme"> :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value="CF-Connecting-IP">CF-Connecting-IP</a-select-option> <a-select-option
value="CF-Connecting-IP">CF-Connecting-IP</a-select-option>
<a-select-option value="X-Real-IP">X-Real-IP</a-select-option> <a-select-option value="X-Real-IP">X-Real-IP</a-select-option>
<a-select-option value="True-Client-IP">True-Client-IP</a-select-option> <a-select-option
value="True-Client-IP">True-Client-IP</a-select-option>
<a-select-option value="X-Client-IP">X-Client-IP</a-select-option> <a-select-option value="X-Client-IP">X-Client-IP</a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
@ -560,14 +798,19 @@
</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" :max="1024"></a-input-number> <a-input-number v-model.number="outbound.mux.concurrency"
:min="-1"
:max="1024"></a-input-number>
</a-form-item> </a-form-item>
<a-form-item label="xudp Concurrency"> <a-form-item label="xudp Concurrency">
<a-input-number v-model.number="outbound.mux.xudpConcurrency" :min="-1" :max="1024"></a-input-number> <a-input-number v-model.number="outbound.mux.xudpConcurrency"
:min="-1" :max="1024"></a-input-number>
</a-form-item> </a-form-item>
<a-form-item label="xudp UDP 443"> <a-form-item label="xudp UDP 443">
<a-select v-model="outbound.mux.xudpProxyUDP443" :dropdown-class-name="themeSwitcher.currentTheme"> <a-select v-model="outbound.mux.xudpProxyUDP443"
<a-select-option v-for="c in ['reject', 'allow', 'skip']" :value="c">[[ c ]]</a-select-option> :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="c in ['reject', 'allow', 'skip']"
:value="c">[[ c ]]</a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
</template> </template>
@ -576,11 +819,13 @@
</a-tab-pane> </a-tab-pane>
<a-tab-pane key="2" tab="JSON" force-render="true"> <a-tab-pane key="2" tab="JSON" force-render="true">
<a-space direction="vertical" :size="10" :style="{ marginTop: '10px' }"> <a-space direction="vertical" :size="10" :style="{ marginTop: '10px' }">
<a-input addon-before='{{ i18n "pages.xray.outbound.link" }}' v-model.trim="outModal.link" <a-input addon-before='{{ i18n "pages.xray.outbound.link" }}'
placeholder="vmess:// vless:// trojan:// ss://"> v-model.trim="outModal.link"
placeholder="vmess:// vless:// trojan:// ss:// hysteria2://">
<a-icon slot="addonAfter" type="form" @click="convertLink"></a-icon> <a-icon slot="addonAfter" type="form" @click="convertLink"></a-icon>
</a-input> </a-input>
<textarea :style="{ position: 'absolute', left: '-800px' }" id="outboundJson"></textarea> <textarea :style="{ position: 'absolute', left: '-800px' }"
id="outboundJson"></textarea>
</a-space> </a-space>
</a-tab-pane> </a-tab-pane>
</a-tabs> </a-tabs>

View file

@ -0,0 +1,44 @@
{{define "form/tun"}}
<a-form :colon="false" :label-col="{ md: {span:8} }"
:wrapper-col="{ md: {span:14} }">
<a-form-item>
<template slot="label">
<a-tooltip>
<template slot="title">
<span>{{ i18n "pages.xray.tun.nameDesc" }}</span>
</template>
Interface Name
<a-icon type="question-circle"></a-icon>
</a-tooltip>
</template>
<a-input v-model.trim="inbound.settings.name"
placeholder="xray0"></a-input>
</a-form-item>
<a-form-item>
<template slot="label">
<a-tooltip>
<template slot="title">
<span>{{ i18n "pages.xray.tun.mtuDesc" }}</span>
</template>
MTU
<a-icon type="question-circle"></a-icon>
</a-tooltip>
</template>
<a-input-number v-model.number="inbound.settings.mtu" :min="1"
:max="9000" placeholder="1500"></a-input-number>
</a-form-item>
<a-form-item>
<template slot="label">
<a-tooltip>
<template slot="title">
<span>{{ i18n "pages.xray.tun.userLevelDesc" }}</span>
</template>
{{ i18n "pages.xray.tun.userLevel" }}
<a-icon type="question-circle"></a-icon>
</a-tooltip>
</template>
<a-input-number v-model.number="inbound.settings.userLevel" :min="0"
placeholder="0"></a-input-number>
</a-form-item>
</a-form>
{{end}}

View file

@ -0,0 +1,84 @@
{{define "form/streamFinalMask"}}
<a-divider :style="{ margin: '5px 0 0' }"></a-divider>
<a-form :colon="false" :label-col="{ md: {span:8} }"
:wrapper-col="{ md: {span:14} }">
<a-form-item label="UDP Masks">
<a-button icon="plus" type="primary" size="small"
@click="inbound.stream.addUdpMask(inbound.stream.network === 'kcp' ? 'mkcp-aes128gcm' : 'xdns')"></a-button>
</a-form-item>
<template
v-if="inbound.stream.finalmask.udp && inbound.stream.finalmask.udp.length > 0">
<a-form v-for="(mask, index) in inbound.stream.finalmask.udp"
:key="index" :colon="false"
:label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
<a-divider :style="{ margin: '0' }"> UDP Mask [[ index + 1 ]]
<a-icon type="delete"
@click="() => inbound.stream.delUdpMask(index)"
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"></a-icon>
</a-divider>
<a-form-item label='Type'>
<a-select v-model="mask.type"
@change="(type) => mask.settings = mask._getDefaultSettings(type, {})"
:dropdown-class-name="themeSwitcher.currentTheme">
<!-- mKCP-specific masks -->
<a-select-option v-if="inbound.stream.network === 'kcp'"
value="mkcp-aes128gcm">
mKCP AES-128-GCM</a-select-option>
<a-select-option v-if="inbound.stream.network === 'kcp'"
value="header-dns">
Header DNS</a-select-option>
<a-select-option v-if="inbound.stream.network === 'kcp'"
value="header-dtls">
Header DTLS 1.2</a-select-option>
<a-select-option v-if="inbound.stream.network === 'kcp'"
value="header-srtp">
Header SRTP</a-select-option>
<a-select-option v-if="inbound.stream.network === 'kcp'"
value="header-utp">
Header uTP</a-select-option>
<a-select-option v-if="inbound.stream.network === 'kcp'"
value="header-wechat">
Header WeChat Video</a-select-option>
<a-select-option v-if="inbound.stream.network === 'kcp'"
value="header-wireguard">
Header WireGuard</a-select-option>
<a-select-option v-if="inbound.stream.network === 'kcp'"
value="mkcp-original">
mKCP Original</a-select-option>
<a-select-option v-if="inbound.stream.network === 'kcp'"
value="xicmp">
xICMP (Experimental)</a-select-option>
<!-- xDNS for TCP/WS/HTTPUpgrade/XHTTP -->
<a-select-option
v-if="['tcp', 'ws', 'httpupgrade', 'xhttp'].includes(inbound.stream.network)"
value="xdns">
xDNS (Experimental)</a-select-option>
</a-select>
</a-form-item>
<!-- Settings for password-based masks -->
<a-form-item label='Password'
v-if="['mkcp-aes128gcm'].includes(mask.type)">
<a-input v-model.trim="mask.settings.password"
placeholder="Obfuscation password"></a-input>
</a-form-item>
<!-- Settings for domain-based masks -->
<a-form-item label='Domain'
v-if="['header-dns', 'xdns'].includes(mask.type)">
<a-input v-model.trim="mask.settings.domain"
placeholder="e.g., www.example.com"></a-input>
</a-form-item>
<!-- Settings for xICMP -->
<a-form-item label='IP'
v-if="mask.type === 'xicmp'">
<a-input v-model.trim="mask.settings.ip"
placeholder="e.g., 1.1.1.1"></a-input>
</a-form-item>
<a-form-item label='ID'
v-if="mask.type === 'xicmp'">
<a-input-number v-model.number="mask.settings.id"
:min="0" :max="65535"></a-input-number>
</a-form-item>
</a-form>
</template>
</a-form>
{{end}}

View file

@ -1,48 +1,32 @@
{{define "form/streamKCP"}} {{define "form/streamKCP"}}
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }"> <a-form :colon="false" :label-col="{ md: {span:8} }"
<a-form-item label='{{ i18n "camouflage" }}'> :wrapper-col="{ md: {span:14} }">
<a-select v-model="inbound.stream.kcp.type" :style="{ width: '50%' }" :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value="none">None</a-select-option>
<a-select-option value="srtp">SRTP</a-select-option>
<a-select-option value="utp">uTP</a-select-option>
<a-select-option value="wechat-video">WeChat</a-select-option>
<a-select-option value="dtls">DTLS 1.2</a-select-option>
<a-select-option value="wireguard">WireGuard</a-select-option>
<a-select-option value="dns">DNS</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<template slot="label">
<a-tooltip>
<template slot="title">
<span>{{ i18n "reset" }}</span>
</template>
{{ i18n "password" }}
<a-icon @click="inbound.stream.kcp.seed = RandomUtil.randomSeq(10)"type="sync"> </a-icon>
</a-tooltip>
</template>
<a-input v-model.trim="inbound.stream.kcp.seed"></a-input>
</a-form-item>
<a-form-item label='MTU'> <a-form-item label='MTU'>
<a-input-number v-model.number="inbound.stream.kcp.mtu" :min="576" :max="1460"></a-input-number> <a-input-number v-model.number="inbound.stream.kcp.mtu" :min="576"
:max="1460"></a-input-number>
</a-form-item> </a-form-item>
<a-form-item label='TTI (ms)'> <a-form-item label='TTI (ms)'>
<a-input-number v-model.number="inbound.stream.kcp.tti" :min="10" :max="100"></a-input-number> <a-input-number v-model.number="inbound.stream.kcp.tti" :min="10"
:max="100"></a-input-number>
</a-form-item> </a-form-item>
<a-form-item label='Uplink (MB/s)'> <a-form-item label='Uplink (MB/s)'>
<a-input-number v-model.number="inbound.stream.kcp.upCap" :min="0"></a-input-number> <a-input-number v-model.number="inbound.stream.kcp.upCap"
:min="0"></a-input-number>
</a-form-item> </a-form-item>
<a-form-item label='Downlink (MB/s)'> <a-form-item label='Downlink (MB/s)'>
<a-input-number v-model.number="inbound.stream.kcp.downCap" :min="0"></a-input-number> <a-input-number v-model.number="inbound.stream.kcp.downCap"
:min="0"></a-input-number>
</a-form-item> </a-form-item>
<a-form-item label='Congestion'> <a-form-item label='Congestion'>
<a-switch v-model="inbound.stream.kcp.congestion"></a-switch> <a-switch v-model="inbound.stream.kcp.congestion"></a-switch>
</a-form-item> </a-form-item>
<a-form-item label='Read Buffer (MB)'> <a-form-item label='Read Buffer (MB)'>
<a-input-number v-model.number="inbound.stream.kcp.readBuffer" :min="0"></a-input-number> <a-input-number v-model.number="inbound.stream.kcp.readBuffer"
:min="0"></a-input-number>
</a-form-item> </a-form-item>
<a-form-item label='Write Buffer (MB)'> <a-form-item label='Write Buffer (MB)'>
<a-input-number v-model.number="inbound.stream.kcp.writeBuffer" :min="0"></a-input-number> <a-input-number v-model.number="inbound.stream.kcp.writeBuffer"
:min="0"></a-input-number>
</a-form-item> </a-form-item>
</a-form> </a-form>
{{end}} {{end}}

View file

@ -1,8 +1,10 @@
{{define "form/streamSettings"}} {{define "form/streamSettings"}}
<!-- select stream network --> <!-- select stream network -->
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }"> <a-form :colon="false" :label-col="{ md: {span:8} }"
:wrapper-col="{ md: {span:14} }">
<a-form-item label='{{ i18n "transmission" }}'> <a-form-item label='{{ i18n "transmission" }}'>
<a-select v-model="inbound.stream.network" :style="{ width: '75%' }" @change="streamNetworkChange" <a-select v-model="inbound.stream.network" :style="{ width: '75%' }"
@change="streamNetworkChange"
:dropdown-class-name="themeSwitcher.currentTheme"> :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value="tcp">TCP (RAW)</a-select-option> <a-select-option value="tcp">TCP (RAW)</a-select-option>
<a-select-option value="kcp">mKCP</a-select-option> <a-select-option value="kcp">mKCP</a-select-option>
@ -48,4 +50,10 @@
<template> <template>
{{template "form/streamSockopt"}} {{template "form/streamSockopt"}}
</template> </template>
<!-- finalmask - only for TCP, WS, HTTPUpgrade, XHTTP, mKCP -->
<template
v-if="['tcp', 'ws', 'httpupgrade', 'xhttp', 'kcp'].includes(inbound.stream.network)">
{{template "form/streamFinalMask"}}
</template>
{{end}} {{end}}

View file

@ -1,5 +1,6 @@
{{define "form/streamXHTTP"}} {{define "form/streamXHTTP"}}
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }"> <a-form :colon="false" :label-col="{ md: {span:8} }"
:wrapper-col="{ md: {span:14} }">
<a-form-item label='{{ i18n "host" }}'> <a-form-item label='{{ i18n "host" }}'>
<a-input v-model.trim="inbound.stream.xhttp.host"></a-input> <a-input v-model.trim="inbound.stream.xhttp.host"></a-input>
</a-form-item> </a-form-item>
@ -7,38 +8,138 @@
<a-input v-model.trim="inbound.stream.xhttp.path"></a-input> <a-input v-model.trim="inbound.stream.xhttp.path"></a-input>
</a-form-item> </a-form-item>
<a-form-item label='{{ i18n "pages.inbounds.stream.tcp.requestHeader" }}'> <a-form-item label='{{ i18n "pages.inbounds.stream.tcp.requestHeader" }}'>
<a-button icon="plus" size="small" @click="inbound.stream.xhttp.addHeader('', '')"></a-button> <a-button icon="plus" size="small"
@click="inbound.stream.xhttp.addHeader('', '')"></a-button>
</a-form-item> </a-form-item>
<a-form-item :wrapper-col="{span:24}"> <a-form-item :wrapper-col="{span:24}">
<a-input-group compact v-for="(header, index) in inbound.stream.xhttp.headers"> <a-input-group compact
v-for="(header, index) in inbound.stream.xhttp.headers">
<a-input :style="{ width: '50%' }" v-model.trim="header.name" <a-input :style="{ width: '50%' }" v-model.trim="header.name"
placeholder='{{ i18n "pages.inbounds.stream.general.name"}}'> placeholder='{{ i18n "pages.inbounds.stream.general.name"}}'>
<template slot="addonBefore" :style="{ margin: '0' }">[[ index+1 ]]</template> <template slot="addonBefore" :style="{ margin: '0' }">[[ index+1
]]</template>
</a-input> </a-input>
<a-input :style="{ width: '50%' }" v-model.trim="header.value" <a-input :style="{ width: '50%' }" v-model.trim="header.value"
placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'> placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'>
<a-button icon="minus" slot="addonAfter" size="small" @click="inbound.stream.xhttp.removeHeader(index)"></a-button> <a-button icon="minus" slot="addonAfter" size="small"
@click="inbound.stream.xhttp.removeHeader(index)"></a-button>
</a-input> </a-input>
</a-input-group> </a-input-group>
</a-form-item> </a-form-item>
<a-form-item label='Mode'> <a-form-item label='Mode'>
<a-select v-model="inbound.stream.xhttp.mode" :style="{ width: '50%' }" <a-select v-model="inbound.stream.xhttp.mode" :style="{ width: '50%' }"
:dropdown-class-name="themeSwitcher.currentTheme"> :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="key in MODE_OPTION" :value="key">[[ key ]]</a-select-option> <a-select-option v-for="key in MODE_OPTION" :value="key">[[ key
]]</a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
<a-form-item label="Max Buffered Upload" v-if="inbound.stream.xhttp.mode === 'packet-up'"> <a-form-item label="Max Buffered Upload"
<a-input-number v-model.number="inbound.stream.xhttp.scMaxBufferedPosts"></a-input-number> v-if="inbound.stream.xhttp.mode === 'packet-up'">
<a-input-number
v-model.number="inbound.stream.xhttp.scMaxBufferedPosts"></a-input-number>
</a-form-item> </a-form-item>
<a-form-item label="Max Upload Size (Byte)" v-if="inbound.stream.xhttp.mode === 'packet-up'"> <a-form-item label="Max Upload Size (Byte)"
<a-input v-model.trim="inbound.stream.xhttp.scMaxEachPostBytes"></a-input> v-if="inbound.stream.xhttp.mode === 'packet-up'">
<a-input
v-model.trim="inbound.stream.xhttp.scMaxEachPostBytes"></a-input>
</a-form-item> </a-form-item>
<a-form-item label="Stream-Up Server" v-if="inbound.stream.xhttp.mode === 'stream-up'"> <a-form-item label="Stream-Up Server"
<a-input v-model.trim="inbound.stream.xhttp.scStreamUpServerSecs"></a-input> v-if="inbound.stream.xhttp.mode === 'stream-up'">
<a-input
v-model.trim="inbound.stream.xhttp.scStreamUpServerSecs"></a-input>
</a-form-item> </a-form-item>
<a-form-item label="Padding Bytes"> <a-form-item label="Padding Bytes">
<a-input v-model.trim="inbound.stream.xhttp.xPaddingBytes"></a-input> <a-input v-model.trim="inbound.stream.xhttp.xPaddingBytes"></a-input>
</a-form-item> </a-form-item>
<a-form-item label="Padding Obfs Mode">
<a-switch v-model="inbound.stream.xhttp.xPaddingObfsMode"></a-switch>
</a-form-item>
<template v-if="inbound.stream.xhttp.xPaddingObfsMode">
<a-form-item label="Padding Key">
<a-input v-model.trim="inbound.stream.xhttp.xPaddingKey"
placeholder="x_padding"></a-input>
</a-form-item>
<a-form-item label="Padding Header">
<a-input v-model.trim="inbound.stream.xhttp.xPaddingHeader"
placeholder="X-Padding"></a-input>
</a-form-item>
<a-form-item label="Padding Placement">
<a-select v-model="inbound.stream.xhttp.xPaddingPlacement"
:dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value>Default (queryInHeader)</a-select-option>
<a-select-option
value="queryInHeader">queryInHeader</a-select-option>
<a-select-option value="header">header</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Padding Method">
<a-select v-model="inbound.stream.xhttp.xPaddingMethod"
:dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value>Default (repeat-x)</a-select-option>
<a-select-option value="repeat-x">repeat-x</a-select-option>
<a-select-option value="tokenish">tokenish</a-select-option>
</a-select>
</a-form-item>
</template>
<a-form-item label="Uplink HTTP Method">
<a-select v-model="inbound.stream.xhttp.uplinkHTTPMethod"
:dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value>Default (POST)</a-select-option>
<a-select-option value="POST">POST</a-select-option>
<a-select-option value="PUT">PUT</a-select-option>
<a-select-option value="GET">GET (packet-up only)</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Session Placement">
<a-select v-model="inbound.stream.xhttp.sessionPlacement"
:dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value>Default (path)</a-select-option>
<a-select-option value="path">path</a-select-option>
<a-select-option value="header">header</a-select-option>
<a-select-option value="cookie">cookie</a-select-option>
<a-select-option value="query">query</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Session Key"
v-if="inbound.stream.xhttp.sessionPlacement && inbound.stream.xhttp.sessionPlacement !== 'path'">
<a-input v-model.trim="inbound.stream.xhttp.sessionKey"
placeholder="x_session"></a-input>
</a-form-item>
<a-form-item label="Sequence Placement">
<a-select v-model="inbound.stream.xhttp.seqPlacement"
:dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value>Default (path)</a-select-option>
<a-select-option value="path">path</a-select-option>
<a-select-option value="header">header</a-select-option>
<a-select-option value="cookie">cookie</a-select-option>
<a-select-option value="query">query</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Sequence Key"
v-if="inbound.stream.xhttp.seqPlacement && inbound.stream.xhttp.seqPlacement !== 'path'">
<a-input v-model.trim="inbound.stream.xhttp.seqKey"
placeholder="x_seq"></a-input>
</a-form-item>
<a-form-item label="Uplink Data Placement"
v-if="inbound.stream.xhttp.mode === 'packet-up'">
<a-select v-model="inbound.stream.xhttp.uplinkDataPlacement"
:dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value>Default (body)</a-select-option>
<a-select-option value="body">body</a-select-option>
<a-select-option value="header">header</a-select-option>
<a-select-option value="query">query</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Uplink Data Key"
v-if="inbound.stream.xhttp.mode === 'packet-up' && inbound.stream.xhttp.uplinkDataPlacement && inbound.stream.xhttp.uplinkDataPlacement !== 'body'">
<a-input v-model.trim="inbound.stream.xhttp.uplinkDataKey"
placeholder="x_data"></a-input>
</a-form-item>
<a-form-item label="Uplink Chunk Size"
v-if="inbound.stream.xhttp.mode === 'packet-up' && inbound.stream.xhttp.uplinkDataPlacement && inbound.stream.xhttp.uplinkDataPlacement !== 'body'">
<a-input-number v-model.number="inbound.stream.xhttp.uplinkChunkSize"
:min="0" placeholder="0 (unlimited)"></a-input-number>
</a-form-item>
<a-form-item label="No SSE Header"> <a-form-item label="No SSE Header">
<a-switch v-model="inbound.stream.xhttp.noSSEHeader"></a-switch> <a-switch v-model="inbound.stream.xhttp.noSSEHeader"></a-switch>
</a-form-item> </a-form-item>

View file

@ -1,11 +1,13 @@
{{define "form/tlsSettings"}} {{define "form/tlsSettings"}}
<!-- tls enable --> <!-- tls enable -->
<a-form v-if="inbound.canEnableTls()" :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }"> <a-form v-if="inbound.canEnableTls()" :colon="false"
:label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
<a-divider :style="{ margin: '3px 0' }"></a-divider> <a-divider :style="{ margin: '3px 0' }"></a-divider>
<a-form-item label='{{ i18n "security" }}'> <a-form-item label='{{ i18n "security" }}'>
<a-radio-group v-model="inbound.stream.security" button-style="solid"> <a-radio-group v-model="inbound.stream.security" button-style="solid">
<a-radio-button value="none">{{ i18n "none" }}</a-radio-button> <a-radio-button value="none">{{ i18n "none" }}</a-radio-button>
<a-radio-button v-if="inbound.canEnableReality()" value="reality">Reality</a-radio-button> <a-radio-button v-if="inbound.canEnableReality()"
value="reality">Reality</a-radio-button>
<a-radio-button value="tls">TLS</a-radio-button> <a-radio-button value="tls">TLS</a-radio-button>
</a-radio-group> </a-radio-group>
</a-form-item> </a-form-item>
@ -16,38 +18,46 @@
<a-input v-model.trim="inbound.stream.tls.sni"></a-input> <a-input v-model.trim="inbound.stream.tls.sni"></a-input>
</a-form-item> </a-form-item>
<a-form-item label="Cipher Suites"> <a-form-item label="Cipher Suites">
<a-select v-model="inbound.stream.tls.cipherSuites" :dropdown-class-name="themeSwitcher.currentTheme"> <a-select v-model="inbound.stream.tls.cipherSuites"
<a-select-option value="">Auto</a-select-option> :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="key,value in TLS_CIPHER_OPTION" :value="key">[[ value ]]</a-select-option> <a-select-option value>Auto</a-select-option>
<a-select-option v-for="key,value in TLS_CIPHER_OPTION" :value="key">[[
value ]]</a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
<a-form-item label="Min/Max Version"> <a-form-item label="Min/Max Version">
<a-input-group compact> <a-input-group compact>
<a-select v-model="inbound.stream.tls.minVersion" :style="{ width: '50%' }" <a-select v-model="inbound.stream.tls.minVersion"
:style="{ width: '50%' }"
:dropdown-class-name="themeSwitcher.currentTheme"> :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="key in TLS_VERSION_OPTION" :value="key">[[ key ]]</a-select-option> <a-select-option v-for="key in TLS_VERSION_OPTION" :value="key">[[ key
]]</a-select-option>
</a-select> </a-select>
<a-select v-model="inbound.stream.tls.maxVersion" :style="{ width: '50%' }" <a-select v-model="inbound.stream.tls.maxVersion"
:style="{ width: '50%' }"
:dropdown-class-name="themeSwitcher.currentTheme"> :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="key in TLS_VERSION_OPTION" :value="key">[[ key ]]</a-select-option> <a-select-option v-for="key in TLS_VERSION_OPTION" :value="key">[[ key
]]</a-select-option>
</a-select> </a-select>
</a-input-group> </a-input-group>
</a-form-item> </a-form-item>
<a-form-item label="uTLS"> <a-form-item label="uTLS">
<a-select v-model="inbound.stream.tls.settings.fingerprint" :style="{ width: '100%' }" <a-select v-model="inbound.stream.tls.settings.fingerprint"
:style="{ width: '100%' }"
:dropdown-class-name="themeSwitcher.currentTheme"> :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value=''>None</a-select-option> <a-select-option value>None</a-select-option>
<a-select-option v-for="key in UTLS_FINGERPRINT" :value="key">[[ key ]]</a-select-option> <a-select-option v-for="key in UTLS_FINGERPRINT" :value="key">[[ key
]]</a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
<a-form-item label="ALPN"> <a-form-item label="ALPN">
<a-select mode="multiple" :dropdown-class-name="themeSwitcher.currentTheme" v-model="inbound.stream.tls.alpn"> <a-select mode="multiple"
<a-select-option v-for="alpn in ALPN_OPTION" :value="alpn">[[ alpn ]]</a-select-option> :dropdown-class-name="themeSwitcher.currentTheme"
v-model="inbound.stream.tls.alpn">
<a-select-option v-for="alpn in ALPN_OPTION" :value="alpn">[[ alpn
]]</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>
@ -57,21 +67,25 @@
<a-form-item label="Session Resumption"> <a-form-item label="Session Resumption">
<a-switch v-model="inbound.stream.tls.enableSessionResumption"></a-switch> <a-switch v-model="inbound.stream.tls.enableSessionResumption"></a-switch>
</a-form-item> </a-form-item>
<a-form-item label="VerifyPeerCertInNames">
<a-input v-model.trim="inbound.stream.tls.verifyPeerCertInNames"></a-input>
</a-form-item>
<a-divider :style="{ margin: '3px 0' }"></a-divider> <a-divider :style="{ margin: '3px 0' }"></a-divider>
<template v-for="cert,index in inbound.stream.tls.certs"> <template v-for="cert,index in inbound.stream.tls.certs">
<a-form-item label='{{ i18n "certificate" }}'> <a-form-item label='{{ i18n "certificate" }}'>
<a-radio-group v-model="cert.useFile" button-style="solid" :style="{ display: 'inline-flex', whiteSpace: 'nowrap', maxWidth: '100%' }"> <a-radio-group v-model="cert.useFile" button-style="solid"
<a-radio-button :value="true" :style="{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }">{{ i18n "pages.inbounds.certificatePath" }}</a-radio-button> :style="{ display: 'inline-flex', whiteSpace: 'nowrap', maxWidth: '100%' }">
<a-radio-button :value="false" :style="{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }">{{ i18n "pages.inbounds.certificateContent" }}</a-radio-button> <a-radio-button :value="true"
:style="{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }">{{
i18n "pages.inbounds.certificatePath" }}</a-radio-button>
<a-radio-button :value="false"
:style="{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }">{{
i18n "pages.inbounds.certificateContent" }}</a-radio-button>
</a-radio-group> </a-radio-group>
</a-form-item> </a-form-item>
<a-form-item label=" "> <a-form-item label=" ">
<a-space> <a-space>
<a-button icon="plus" v-if="index === 0" type="primary" size="small" @click="inbound.stream.tls.addCert()"></a-button> <a-button icon="plus" v-if="index === 0" type="primary" size="small"
<a-button icon="minus" v-if="inbound.stream.tls.certs.length>1" type="primary" size="small" @click="inbound.stream.tls.addCert()"></a-button>
<a-button icon="minus" v-if="inbound.stream.tls.certs.length>1"
type="primary" size="small"
@click="inbound.stream.tls.removeCert(index)"></a-button> @click="inbound.stream.tls.removeCert(index)"></a-button>
</a-space> </a-space>
</a-form-item> </a-form-item>
@ -83,7 +97,8 @@
<a-input v-model.trim="cert.keyFile"></a-input> <a-input v-model.trim="cert.keyFile"></a-input>
</a-form-item> </a-form-item>
<a-form-item label=" "> <a-form-item label=" ">
<a-button type="primary" icon="import" @click="setDefaultCertData(index)"> <a-button type="primary" icon="import"
@click="setDefaultCertData(index)">
{{ i18n "pages.inbounds.setDefaultCert" }}</a-button> {{ i18n "pages.inbounds.setDefaultCert" }}</a-button>
</a-form-item> </a-form-item>
</template> </template>
@ -99,8 +114,10 @@
<a-switch v-model="cert.oneTimeLoading"></a-switch> <a-switch v-model="cert.oneTimeLoading"></a-switch>
</a-form-item> </a-form-item>
<a-form-item label='Usage Option'> <a-form-item label='Usage Option'>
<a-select v-model="cert.usage" :style="{ width: '50%' }" :dropdown-class-name="themeSwitcher.currentTheme"> <a-select v-model="cert.usage" :style="{ width: '50%' }"
<a-select-option v-for="key in USAGE_OPTION" :value="key">[[ key ]]</a-select-option> :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="key in USAGE_OPTION" :value="key">[[ key
]]</a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
<a-form-item label="Build Chain" v-if="cert.usage === 'issue'"> <a-form-item label="Build Chain" v-if="cert.usage === 'issue'">
@ -116,12 +133,14 @@
<a-form-item label='ECH force query'> <a-form-item label='ECH force query'>
<a-select v-model="inbound.stream.tls.echForceQuery" <a-select v-model="inbound.stream.tls.echForceQuery"
:dropdown-class-name="themeSwitcher.currentTheme"> :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="key in ['none', 'half', 'full']" :value="key">[[ key ]]</a-select-option> <a-select-option v-for="key in ['none', 'half', 'full']" :value="key">[[
key ]]</a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
<a-form-item label=" "> <a-form-item label=" ">
<a-space> <a-space>
<a-button type="primary" icon="import" @click="getNewEchCert">Get New ECH Cert</a-button> <a-button type="primary" icon="import" @click="getNewEchCert">Get New
ECH Cert</a-button>
<a-button danger @click="clearEchCert">Clear</a-button> <a-button danger @click="clearEchCert">Clear</a-button>
</a-space> </a-space>
</a-form-item> </a-form-item>

View file

@ -1602,7 +1602,6 @@
if (payload && Array.isArray(payload)) { if (payload && Array.isArray(payload)) {
// Use setInbounds to properly convert to DBInbound objects with methods // Use setInbounds to properly convert to DBInbound objects with methods
this.setInbounds(payload); this.setInbounds(payload);
this.searchInbounds(this.searchKey);
} }
}); });
@ -1614,7 +1613,19 @@
// Update online clients list in real-time // Update online clients list in real-time
if (payload && Array.isArray(payload.onlineClients)) { if (payload && Array.isArray(payload.onlineClients)) {
this.onlineClients = payload.onlineClients; const nextOnlineClients = payload.onlineClients;
let onlineChanged = this.onlineClients.length !== nextOnlineClients.length;
if (!onlineChanged) {
const prevSet = new Set(this.onlineClients);
for (const email of nextOnlineClients) {
if (!prevSet.has(email)) {
onlineChanged = true;
break;
}
}
}
this.onlineClients = nextOnlineClients;
if (onlineChanged) {
// Recalculate client counts to update online status // Recalculate client counts to update online status
this.dbInbounds.forEach(dbInbound => { this.dbInbounds.forEach(dbInbound => {
const inbound = this.inbounds.find(ib => ib.id === dbInbound.id); const inbound = this.inbounds.find(ib => ib.id === dbInbound.id);
@ -1622,6 +1633,11 @@
this.clientCount[dbInbound.id] = this.getClientCounts(dbInbound, inbound); this.clientCount[dbInbound.id] = this.getClientCounts(dbInbound, inbound);
} }
}); });
if (this.enableFilter) {
this.filterInbounds();
}
}
} }
// Update last online map in real-time // Update last online map in real-time

View file

@ -1,5 +1,8 @@
{{define "modals/inboundInfoModal"}} {{define "modals/inboundInfoModal"}}
<a-modal id="inbound-info-modal" v-model="infoModal.visible" title='{{ i18n "pages.inbounds.details"}}' :closable="true" :mask-closable="true" :footer="null" width="600px" :class="themeSwitcher.currentTheme"> <a-modal id="inbound-info-modal" v-model="infoModal.visible"
title='{{ i18n "pages.inbounds.details"}}' :closable="true"
:mask-closable="true" :footer="null" width="600px"
:class="themeSwitcher.currentTheme">
<a-row> <a-row>
<a-col :xs="24" :md="12"> <a-col :xs="24" :md="12">
<table> <table>
@ -26,7 +29,8 @@
</table> </table>
</a-col> </a-col>
<a-col :xs="24" :md="12"> <a-col :xs="24" :md="12">
<template v-if="dbInbound.isVMess || dbInbound.isVLess || dbInbound.isTrojan || dbInbound.isSS"> <template
v-if="dbInbound.isVMess || dbInbound.isVLess || dbInbound.isTrojan || dbInbound.isSS">
<table> <table>
<tr> <tr>
<td>{{ i18n "transmission" }}</td> <td>{{ i18n "transmission" }}</td>
@ -34,7 +38,8 @@
<a-tag color="green">[[ inbound.network ]]</a-tag> <a-tag color="green">[[ inbound.network ]]</a-tag>
</td> </td>
</tr> </tr>
<template v-if="inbound.isTcp || inbound.isWs || inbound.isHttpupgrade || inbound.isXHTTP"> <template
v-if="inbound.isTcp || inbound.isWs || inbound.isHttpupgrade || inbound.isXHTTP">
<tr> <tr>
<td>{{ i18n "host" }}</td> <td>{{ i18n "host" }}</td>
<td v-if="inbound.host"> <td v-if="inbound.host">
@ -66,26 +71,13 @@
</td> </td>
</tr> </tr>
</template> </template>
<template v-if="inbound.isKcp">
<tr>
<td>kcp {{ i18n "encryption" }}</td>
<td>
<a-tag>[[ inbound.kcpType ]]</a-tag>
</td>
</tr>
<tr>
<td>kcp {{ i18n "password" }}</td>
<td>
<a-tag>[[ inbound.kcpSeed ]]</a-tag>
</td>
</tr>
</template>
<template v-if="inbound.isGrpc"> <template v-if="inbound.isGrpc">
<tr> <tr>
<td>grpc serviceName</td> <td>grpc serviceName</td>
<td> <td>
<a-tooltip :title="[[ inbound.serviceName ]]"> <a-tooltip :title="[[ inbound.serviceName ]]">
<a-tag class="info-large-tag">[[ inbound.serviceName ]]</a-tag> <a-tag class="info-large-tag">[[ inbound.serviceName
]]</a-tag>
</a-tooltip> </a-tooltip>
<tr> <tr>
<td>grpc multiMode</td> <td>grpc multiMode</td>
@ -99,25 +91,34 @@
</a-col> </a-col>
<template v-if="dbInbound.hasLink()"> <template v-if="dbInbound.hasLink()">
{{ i18n "security" }} {{ i18n "security" }}
<a-tag :color="inbound.stream.security == 'none' ? 'red' : 'green'">[[ inbound.stream.security ]]</a-tag> <a-tag :color="inbound.stream.security == 'none' ? 'red' : 'green'">[[
inbound.stream.security ]]</a-tag>
<br /> <br />
<td>Authentication</td> <td>Authentication</td>
<a-tag v-if="inbound.settings.selectedAuth" color="green">[[ inbound.settings.selectedAuth ? inbound.settings.selectedAuth : '' ]]</a-tag> <a-tag v-if="inbound.settings.selectedAuth" color="green">[[
inbound.settings.selectedAuth ? inbound.settings.selectedAuth : ''
]]</a-tag>
<a-tag v-else color="red">{{ i18n "none" }}</a-tag> <a-tag v-else color="red">{{ i18n "none" }}</a-tag>
<br /> <br />
{{ i18n "encryption" }} {{ i18n "encryption" }}
<a-tag class="info-large-tag" :color="inbound.settings.encryption ? 'green' : 'red'">[[ inbound.settings.encryption ? inbound.settings.encryption : '' ]]</a-tag> <a-tag class="info-large-tag"
:color="inbound.settings.encryption ? 'green' : 'red'">[[
inbound.settings.encryption ? inbound.settings.encryption : ''
]]</a-tag>
<a-tooltip title='{{ i18n "copy" }}'> <a-tooltip title='{{ i18n "copy" }}'>
<a-button size="small" icon="snippets" @click="copy(inbound.settings.encryption)"></a-button> <a-button size="small" icon="snippets"
@click="copy(inbound.settings.encryption)"></a-button>
</a-tooltip> </a-tooltip>
<br /> <br />
<template v-if="inbound.stream.security != 'none'"> <template v-if="inbound.stream.security != 'none'">
{{ i18n "domainName" }} {{ i18n "domainName" }}
<a-tag v-if="inbound.serverName" color="green">[[ inbound.serverName ? inbound.serverName : '' ]]</a-tag> <a-tag v-if="inbound.serverName" color="green">[[ inbound.serverName
? inbound.serverName : '' ]]</a-tag>
<a-tag v-else color="orange">{{ i18n "none" }}</a-tag> <a-tag v-else color="orange">{{ i18n "none" }}</a-tag>
</template> </template>
</template> </template>
<table v-if="dbInbound.isSS" :style="{ marginBottom: '10px', width: '100%' }"> <table v-if="dbInbound.isSS"
:style="{ marginBottom: '10px', width: '100%' }">
<tr> <tr>
<td>{{ i18n "encryption" }}</td> <td>{{ i18n "encryption" }}</td>
<td> <td>
@ -128,7 +129,8 @@
<td>{{ i18n "password" }}</td> <td>{{ i18n "password" }}</td>
<td> <td>
<a-tooltip :title="[[ inbound.settings.password ]]"> <a-tooltip :title="[[ inbound.settings.password ]]">
<a-tag class="info-large-tag">[[ inbound.settings.password ]]</a-tag> <a-tag class="info-large-tag">[[ inbound.settings.password
]]</a-tag>
</a-tooltip> </a-tooltip>
</td> </td>
</tr> </tr>
@ -145,7 +147,8 @@
<tr> <tr>
<td>{{ i18n "pages.inbounds.email" }}</td> <td>{{ i18n "pages.inbounds.email" }}</td>
<td v-if="infoModal.clientSettings.email"> <td v-if="infoModal.clientSettings.email">
<a-tag color="green">[[ infoModal.clientSettings.email ]]</a-tag> <a-tag color="green">[[ infoModal.clientSettings.email
]]</a-tag>
</td> </td>
<td v-else> <td v-else>
<a-tag color="red">{{ i18n "none" }}</a-tag> <a-tag color="red">{{ i18n "none" }}</a-tag>
@ -176,30 +179,40 @@
<td>{{ i18n "password" }}</td> <td>{{ i18n "password" }}</td>
<td> <td>
<a-tooltip :title="[[ infoModal.clientSettings.password ]]"> <a-tooltip :title="[[ infoModal.clientSettings.password ]]">
<a-tag class="info-large-tag">[[ infoModal.clientSettings.password ]]</a-tag> <a-tag class="info-large-tag">[[
infoModal.clientSettings.password ]]</a-tag>
</a-tooltip> </a-tooltip>
</td> </td>
</tr> </tr>
<tr> <tr>
<td>{{ i18n "status" }}</td> <td>{{ i18n "status" }}</td>
<td> <td>
<a-tag v-if="isDepleted" color="red">{{ i18n "depleted" }}</a-tag> <a-tag v-if="isDepleted" color="red">{{ i18n "depleted"
<a-tag v-else-if="isEnable" color="green">{{ i18n "enabled" }}</a-tag> }}</a-tag>
<a-tag v-else-if="isEnable" color="green">{{ i18n "enabled"
}}</a-tag>
<a-tag v-else>{{ i18n "disabled" }}</a-tag> <a-tag v-else>{{ i18n "disabled" }}</a-tag>
</td> </td>
</tr> </tr>
<tr v-if="infoModal.clientStats"> <tr v-if="infoModal.clientStats">
<td>{{ i18n "usage" }}</td> <td>{{ i18n "usage" }}</td>
<td> <td>
<a-tag color="green">[[ SizeFormatter.sizeFormat(infoModal.clientStats.up + infoModal.clientStats.down) ]]</a-tag> <a-tag color="green">[[
<a-tag>↑ [[ SizeFormatter.sizeFormat(infoModal.clientStats.up) ]] / [[ SizeFormatter.sizeFormat(infoModal.clientStats.down) ]] ↓</a-tag> SizeFormatter.sizeFormat(infoModal.clientStats.up +
infoModal.clientStats.down) ]]</a-tag>
<a-tag>↑ [[ SizeFormatter.sizeFormat(infoModal.clientStats.up)
]] / [[ SizeFormatter.sizeFormat(infoModal.clientStats.down)
]] ↓</a-tag>
</td> </td>
</tr> </tr>
<tr> <tr>
<td>{{ i18n "pages.inbounds.createdAt" }}</td> <td>{{ i18n "pages.inbounds.createdAt" }}</td>
<td> <td>
<template v-if="infoModal.clientSettings && infoModal.clientSettings.created_at"> <template
<a-tag>[[ IntlUtil.formatDate(infoModal.clientSettings.created_at) ]]</a-tag> v-if="infoModal.clientSettings && infoModal.clientSettings.created_at">
<a-tag>[[
IntlUtil.formatDate(infoModal.clientSettings.created_at)
]]</a-tag>
</template> </template>
<template v-else> <template v-else>
<a-tag>-</a-tag> <a-tag>-</a-tag>
@ -209,8 +222,11 @@
<tr> <tr>
<td>{{ i18n "pages.inbounds.updatedAt" }}</td> <td>{{ i18n "pages.inbounds.updatedAt" }}</td>
<td> <td>
<template v-if="infoModal.clientSettings && infoModal.clientSettings.updated_at"> <template
<a-tag>[[ IntlUtil.formatDate(infoModal.clientSettings.updated_at) ]]</a-tag> v-if="infoModal.clientSettings && infoModal.clientSettings.updated_at">
<a-tag>[[
IntlUtil.formatDate(infoModal.clientSettings.updated_at)
]]</a-tag>
</template> </template>
<template v-else> <template v-else>
<a-tag>-</a-tag> <a-tag>-</a-tag>
@ -220,14 +236,17 @@
<tr> <tr>
<td>{{ i18n "lastOnline" }}</td> <td>{{ i18n "lastOnline" }}</td>
<td> <td>
<a-tag>[[ app.formatLastOnline(infoModal.clientSettings && infoModal.clientSettings.email ? infoModal.clientSettings.email : '') ]]</a-tag> <a-tag>[[ app.formatLastOnline(infoModal.clientSettings &&
infoModal.clientSettings.email ?
infoModal.clientSettings.email : '') ]]</a-tag>
</td> </td>
</tr> </tr>
<tr v-if="infoModal.clientSettings.comment"> <tr v-if="infoModal.clientSettings.comment">
<td>{{ i18n "comment" }}</td> <td>{{ i18n "comment" }}</td>
<td> <td>
<a-tooltip :title="[[ infoModal.clientSettings.comment ]]"> <a-tooltip :title="[[ infoModal.clientSettings.comment ]]">
<a-tag class="info-large-tag">[[ infoModal.clientSettings.comment ]]</a-tag> <a-tag class="info-large-tag">[[
infoModal.clientSettings.comment ]]</a-tag>
</a-tooltip> </a-tooltip>
</td> </td>
</tr> </tr>
@ -237,21 +256,40 @@
<a-tag>[[ infoModal.clientSettings.limitIp ]]</a-tag> <a-tag>[[ infoModal.clientSettings.limitIp ]]</a-tag>
</td> </td>
</tr> </tr>
<tr v-if="app.ipLimitEnable && infoModal.clientSettings.limitIp > 0"> <tr
v-if="app.ipLimitEnable && infoModal.clientSettings.limitIp > 0">
<td>{{ i18n "pages.inbounds.IPLimitlog" }}</td> <td>{{ i18n "pages.inbounds.IPLimitlog" }}</td>
<td> <td>
<a-tag>[[ infoModal.clientIps ]]</a-tag> <div
<a-icon type="sync" :spin="refreshing" @click="refreshIPs" :style="{ margin: '0 5px' }"></a-icon> style="max-height: 150px; overflow-y: auto; text-align: left;">
<a-tooltip :title="[[ dbInbound.address ]]"> <div
v-if="infoModal.clientIpsArray && infoModal.clientIpsArray.length > 0">
<a-tag
v-for="(ipInfo, idx) in infoModal.clientIpsArray"
:key="idx"
color="blue"
style="margin: 2px 0; display: block; font-family: monospace; font-size: 11px;">
[[ formatIpInfo(ipInfo) ]]
</a-tag>
</div>
<a-tag v-else>[[ infoModal.clientIps || 'No IP Record'
]]</a-tag>
</div>
<div style="margin-top: 5px;">
<a-icon type="sync" :spin="refreshing" @click="refreshIPs"
:style="{ margin: '0 5px' }"></a-icon>
<a-tooltip>
<template slot="title"> <template slot="title">
<span>{{ i18n "pages.inbounds.IPLimitlogclear" }}</span> <span>{{ i18n "pages.inbounds.IPLimitlogclear" }}</span>
</template> </template>
<a-icon type="delete" @click="clearClientIps"></a-icon> <a-icon type="delete" @click="clearClientIps"></a-icon>
</a-tooltip> </a-tooltip>
</div>
</td> </td>
</tr> </tr>
</table> </table>
<table :style="{ display: 'inline-table', marginBlock: '10px', width: '100%', textAlign: 'center' }"> <table
:style="{ display: 'inline-table', marginBlock: '10px', width: '100%', textAlign: 'center' }">
<tr> <tr>
<th>{{ i18n "remained" }}</th> <th>{{ i18n "remained" }}</th>
<th>{{ i18n "pages.inbounds.totalFlow" }}</th> <th>{{ i18n "pages.inbounds.totalFlow" }}</th>
@ -259,51 +297,73 @@
</tr> </tr>
<tr> <tr>
<td> <td>
<a-tag v-if="infoModal.clientStats && infoModal.clientSettings.totalGB > 0" :color="statsColor(infoModal.clientStats)"> [[ getRemStats() ]] </a-tag> <a-tag
v-if="infoModal.clientStats && infoModal.clientSettings.totalGB > 0"
:color="statsColor(infoModal.clientStats)"> [[ getRemStats()
]] </a-tag>
</td> </td>
<td> <td>
<a-tag v-if="infoModal.clientSettings.totalGB > 0" :color="statsColor(infoModal.clientStats)"> [[ SizeFormatter.sizeFormat(infoModal.clientSettings.totalGB) ]] </a-tag> <a-tag v-if="infoModal.clientSettings.totalGB > 0"
:color="statsColor(infoModal.clientStats)"> [[
SizeFormatter.sizeFormat(infoModal.clientSettings.totalGB) ]]
</a-tag>
<a-tag v-else color="purple" class="infinite-tag"> <a-tag v-else color="purple" class="infinite-tag">
<svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor"> <svg height="10px" width="14px" viewBox="0 0 640 512"
<path d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z" fill="currentColor"></path> fill="currentColor">
<path
d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z"
fill="currentColor"></path>
</svg> </svg>
</a-tag> </a-tag>
</td> </td>
<td> <td>
<template v-if="infoModal.clientSettings.expiryTime > 0"> <template v-if="infoModal.clientSettings.expiryTime > 0">
<a-tag :color="ColorUtils.usageColor(new Date().getTime(), app.expireDiff, infoModal.clientSettings.expiryTime)"> <a-tag
[[ IntlUtil.formatDate(infoModal.clientSettings.expiryTime) ]] :color="ColorUtils.usageColor(new Date().getTime(), app.expireDiff, infoModal.clientSettings.expiryTime)">
[[ IntlUtil.formatDate(infoModal.clientSettings.expiryTime)
]]
</a-tag> </a-tag>
</template> </template>
<a-tag v-else-if="infoModal.clientSettings.expiryTime < 0" color="green">[[ infoModal.clientSettings.expiryTime / -86400000 ]] {{ i18n "pages.client.days" }} <a-tag v-else-if="infoModal.clientSettings.expiryTime < 0"
color="green">[[ infoModal.clientSettings.expiryTime /
-86400000 ]] {{ i18n "pages.client.days" }}
</a-tag> </a-tag>
<a-tag v-else color="purple" class="infinite-tag"> <a-tag v-else color="purple" class="infinite-tag">
<svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor"> <svg height="10px" width="14px" viewBox="0 0 640 512"
<path d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z" fill="currentColor"></path> fill="currentColor">
<path
d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z"
fill="currentColor"></path>
</svg> </svg>
</a-tag> </a-tag>
</td> </td>
</tr> </tr>
</table> </table>
<template v-if="app.subSettings.enable && infoModal.clientSettings.subId"> <template
v-if="app.subSettings.enable && infoModal.clientSettings.subId">
<a-divider>Subscription URL</a-divider> <a-divider>Subscription URL</a-divider>
<tr-info-row class="tr-info-row"> <tr-info-row class="tr-info-row">
<tr-info-title class="tr-info-title"> <tr-info-title class="tr-info-title">
<a-tag color="purple">Subscription Link</a-tag> <a-tag color="purple">Subscription Link</a-tag>
<a-tooltip title='{{ i18n "copy" }}'> <a-tooltip title='{{ i18n "copy" }}'>
<a-button size="small" icon="snippets" @click="copy(infoModal.subLink)"></a-button> <a-button size="small" icon="snippets"
@click="copy(infoModal.subLink)"></a-button>
</a-tooltip> </a-tooltip>
</tr-info-title> </tr-info-title>
<a :href="[[ infoModal.subLink ]]" target="_blank">[[ infoModal.subLink ]]</a> <a :href="[[ infoModal.subLink ]]" target="_blank">[[
infoModal.subLink ]]</a>
</tr-info-row> </tr-info-row>
<tr-info-row class="tr-info-row" v-if="app.subSettings.subJsonEnable"> <tr-info-row class="tr-info-row"
v-if="app.subSettings.subJsonEnable">
<tr-info-title class="tr-info-title"> <tr-info-title class="tr-info-title">
<a-tag color="purple">Json Link</a-tag> <a-tag color="purple">Json Link</a-tag>
<a-tooltip title='{{ i18n "copy" }}'> <a-tooltip title='{{ i18n "copy" }}'>
<a-button size="small" icon="snippets" @click="copy(infoModal.subJsonLink)"></a-button> <a-button size="small" icon="snippets"
@click="copy(infoModal.subJsonLink)"></a-button>
</a-tooltip> </a-tooltip>
</tr-info-title> </tr-info-title>
<a :href="[[ infoModal.subJsonLink ]]" target="_blank">[[ infoModal.subJsonLink ]]</a> <a :href="[[ infoModal.subJsonLink ]]" target="_blank">[[
infoModal.subJsonLink ]]</a>
</tr-info-row> </tr-info-row>
</template> </template>
<template v-if="app.tgBotEnable && infoModal.clientSettings.tgId"> <template v-if="app.tgBotEnable && infoModal.clientSettings.tgId">
@ -312,18 +372,22 @@
<tr-info-title class="tr-info-title"> <tr-info-title class="tr-info-title">
<a-tag color="blue">[[ infoModal.clientSettings.tgId ]]</a-tag> <a-tag color="blue">[[ infoModal.clientSettings.tgId ]]</a-tag>
<a-tooltip title='{{ i18n "copy" }}'> <a-tooltip title='{{ i18n "copy" }}'>
<a-button size="small" icon="snippets" @click="copy(infoModal.clientSettings.tgId)"></a-button> <a-button size="small" icon="snippets"
@click="copy(infoModal.clientSettings.tgId)"></a-button>
</a-tooltip> </a-tooltip>
</tr-info-title> </tr-info-title>
</tr-info-row> </tr-info-row>
</template> </template>
<template v-if="dbInbound.hasLink()"> <template v-if="dbInbound.hasLink()">
<a-divider>URL</a-divider> <a-divider>URL</a-divider>
<tr-info-row v-for="(link,index) in infoModal.links" class="tr-info-row"> <tr-info-row v-for="(link,index) in infoModal.links"
class="tr-info-row">
<tr-info-title class="tr-info-title"> <tr-info-title class="tr-info-title">
<a-tag class="tr-info-tag" color="green">[[ link.remark ]]</a-tag> <a-tag class="tr-info-tag" color="green">[[ link.remark
]]</a-tag>
<a-tooltip title='{{ i18n "copy" }}'> <a-tooltip title='{{ i18n "copy" }}'>
<a-button :style="{ minWidth: '24px' }" size="small" icon="snippets" @click="copy(link.link)"></a-button> <a-button :style="{ minWidth: '24px' }" size="small"
icon="snippets" @click="copy(link.link)"></a-button>
</a-tooltip> </a-tooltip>
</tr-info-title> </tr-info-title>
<code>[[ link.link ]]</code> <code>[[ link.link ]]</code>
@ -333,17 +397,21 @@
<template v-else> <template v-else>
<template v-if="dbInbound.isSS && !inbound.isSSMultiUser"> <template v-if="dbInbound.isSS && !inbound.isSSMultiUser">
<a-divider>URL</a-divider> <a-divider>URL</a-divider>
<tr-info-row v-for="(link,index) in infoModal.links" class="tr-info-row"> <tr-info-row v-for="(link,index) in infoModal.links"
class="tr-info-row">
<tr-info-title class="tr-info-title"> <tr-info-title class="tr-info-title">
<a-tag class="tr-info-tag" color="green">[[ link.remark ]]</a-tag> <a-tag class="tr-info-tag" color="green">[[ link.remark
]]</a-tag>
<a-tooltip title='{{ i18n "copy" }}'> <a-tooltip title='{{ i18n "copy" }}'>
<a-button :style="{ minWidth: '24px' }" size="small" icon="snippets" @click="copy(link.link)"></a-button> <a-button :style="{ minWidth: '24px' }" size="small"
icon="snippets" @click="copy(link.link)"></a-button>
</a-tooltip> </a-tooltip>
</tr-info-title> </tr-info-title>
<code>[[ link.link ]]</code> <code>[[ link.link ]]</code>
</tr-info-row> </tr-info-row>
</template> </template>
<table v-if="inbound.protocol == Protocols.TUNNEL" class="tr-info-table"> <table v-if="inbound.protocol == Protocols.TUNNEL"
class="tr-info-table">
<tr> <tr>
<th>{{ i18n "pages.inbounds.targetAddress" }}</th> <th>{{ i18n "pages.inbounds.targetAddress" }}</th>
<th>{{ i18n "pages.inbounds.destinationPort" }}</th> <th>{{ i18n "pages.inbounds.destinationPort" }}</th>
@ -361,7 +429,8 @@
<a-tag color="green">[[ inbound.settings.network ]]</a-tag> <a-tag color="green">[[ inbound.settings.network ]]</a-tag>
</td> </td>
<td> <td>
<a-tag color="green">[[ inbound.settings.followRedirect ]]</a-tag> <a-tag color="green">[[ inbound.settings.followRedirect
]]</a-tag>
</td> </td>
</tr> </tr>
</table> </table>
@ -464,13 +533,20 @@
<tr-info-title class="tr-info-title"> <tr-info-title class="tr-info-title">
<a-tag color="blue">Config</a-tag> <a-tag color="blue">Config</a-tag>
<a-tooltip title='{{ i18n "copy" }}'> <a-tooltip title='{{ i18n "copy" }}'>
<a-button :style="{ minWidth: '24px' }" size="small" icon="snippets" @click="copy(infoModal.links[index])"></a-button> <a-button :style="{ minWidth: '24px' }" size="small"
icon="snippets"
@click="copy(infoModal.links[index])"></a-button>
</a-tooltip> </a-tooltip>
<a-tooltip title='{{ i18n "download" }}'> <a-tooltip title='{{ i18n "download" }}'>
<a-button :style="{ minWidth: '24px' }" size="small" icon="download" @click="FileManager.downloadTextFile(infoModal.links[index], `peer-${index + 1}.conf`)"></a-button> <a-button :style="{ minWidth: '24px' }" size="small"
icon="download"
@click="FileManager.downloadTextFile(infoModal.links[index], `peer-${index + 1}.conf`)"></a-button>
</a-tooltip> </a-tooltip>
</tr-info-title> </tr-info-title>
<div v-html="infoModal.links[index].replaceAll(`\n`,`<br />`)" :style="{ borderRadius: '1rem', padding: '0.5rem' }" class="client-table-odd-row"> <div
v-html="infoModal.links[index].replaceAll(`\n`,`<br />`)"
:style="{ borderRadius: '1rem', padding: '0.5rem' }"
class="client-table-odd-row">
</div> </div>
</tr-info-row> </tr-info-row>
</td> </td>
@ -482,12 +558,73 @@
<script> <script>
function refreshIPs(email) { function refreshIPs(email) {
return HttpUtil.post(`/panel/api/inbounds/clientIps/${email}`).then((msg) => { return HttpUtil.post(`/panel/api/inbounds/clientIps/${email}`).then((msg) => {
if (msg.success) { if (!msg.success) {
try { return { text: 'No IP Record', array: [] };
return JSON.parse(msg.obj).join(', ');
} catch (e) {
return msg.obj;
} }
const formatIpRecord = (record) => {
if (record == null) {
return '';
}
if (typeof record === 'string' || typeof record === 'number') {
return String(record);
}
const ip = record.ip || record.IP || '';
const timestamp = record.timestamp || record.Timestamp || 0;
if (!ip) {
return String(record);
}
if (!timestamp) {
return String(ip);
}
const date = new Date(Number(timestamp) * 1000);
const timeStr = date
.toLocaleString('en-GB', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})
.replace(',', '');
return `${ip} (${timeStr})`;
};
try {
let ips = msg.obj;
// If msg.obj is a string, try to parse it
if (typeof ips === 'string') {
try {
ips = JSON.parse(ips);
} catch (e) {
return { text: String(ips), array: [String(ips)] };
}
}
// Normalize single object response to array
if (ips && !Array.isArray(ips) && typeof ips === 'object') {
ips = [ips];
}
// New format or object array
if (Array.isArray(ips) && ips.length > 0 && typeof ips[0] === 'object') {
const result = ips.map((item) => formatIpRecord(item)).filter(Boolean);
return { text: result.join(' | '), array: result };
}
// Old format - simple array of IPs
if (Array.isArray(ips) && ips.length > 0) {
const result = ips.map((ip) => String(ip));
return { text: result.join(', '), array: result };
}
// Fallback for any other format
return { text: String(ips), array: [String(ips)] };
} catch (e) {
return { text: 'Error loading IPs', array: [] };
} }
}); });
} }
@ -506,6 +643,7 @@
subLink: '', subLink: '',
subJsonLink: '', subJsonLink: '',
clientIps: '', clientIps: '',
clientIpsArray: [],
show(dbInbound, index) { show(dbInbound, index) {
this.index = index; this.index = index;
this.inbound = dbInbound.toInbound(); this.inbound = dbInbound.toInbound();
@ -523,8 +661,9 @@
].includes(this.inbound.protocol) ].includes(this.inbound.protocol)
) { ) {
if (app.ipLimitEnable && this.clientSettings.limitIp) { if (app.ipLimitEnable && this.clientSettings.limitIp) {
refreshIPs(this.clientStats.email).then((ips) => { refreshIPs(this.clientStats.email).then((result) => {
this.clientIps = ips; this.clientIps = result.text;
this.clientIpsArray = result.array;
}) })
} }
} }
@ -595,6 +734,35 @@
}, },
}, },
methods: { methods: {
formatIpInfo(ipInfo) {
if (ipInfo == null) {
return '';
}
if (typeof ipInfo === 'string' || typeof ipInfo === 'number') {
return String(ipInfo);
}
const ip = ipInfo.ip || ipInfo.IP || '';
const timestamp = ipInfo.timestamp || ipInfo.Timestamp || 0;
if (!ip) {
return String(ipInfo);
}
if (!timestamp) {
return String(ip);
}
const date = new Date(Number(timestamp) * 1000);
const timeStr = date
.toLocaleString('en-GB', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})
.replace(',', '');
return `${ip} (${timeStr})`;
},
copy(content) { copy(content) {
ClipboardManager ClipboardManager
.copyText(content) .copyText(content)
@ -612,8 +780,9 @@
refreshIPs() { refreshIPs() {
this.refreshing = true; this.refreshing = true;
refreshIPs(this.infoModal.clientStats.email) refreshIPs(this.infoModal.clientStats.email)
.then((ips) => { .then((result) => {
this.infoModal.clientIps = ips; this.infoModal.clientIps = result.text;
this.infoModal.clientIpsArray = result.array;
}) })
.finally(() => { .finally(() => {
this.refreshing = false; this.refreshing = false;
@ -626,6 +795,7 @@
return; return;
} }
this.infoModal.clientIps = 'No IP Record'; this.infoModal.clientIps = 'No IP Record';
this.infoModal.clientIpsArray = [];
}) })
.catch(() => {}); .catch(() => {});
}, },

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

@ -15,13 +15,6 @@
<a-switch v-model="allSetting.subJsonEnable"></a-switch> <a-switch v-model="allSetting.subJsonEnable"></a-switch>
</template> </template>
</a-setting-list-item> </a-setting-list-item>
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subTitle"}}</template>
<template #description>{{ i18n "pages.settings.subTitleDesc"}}</template>
<template #control>
<a-input type="text" v-model="allSetting.subTitle"></a-input>
</template>
</a-setting-list-item>
<a-setting-list-item paddings="small"> <a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subListen"}}</template> <template #title>{{ i18n "pages.settings.subListen"}}</template>
<template #description>{{ i18n "pages.settings.subListenDesc"}}</template> <template #description>{{ i18n "pages.settings.subListenDesc"}}</template>
@ -78,6 +71,50 @@
<a-switch v-model="allSetting.subShowInfo"></a-switch> <a-switch v-model="allSetting.subShowInfo"></a-switch>
</template> </template>
</a-setting-list-item> </a-setting-list-item>
<a-divider>{{ i18n "pages.xray.basicTemplate"}}</a-divider>
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subTitle"}}</template>
<template #description>{{ i18n "pages.settings.subTitleDesc"}}</template>
<template #control>
<a-input type="text" v-model="allSetting.subTitle"></a-input>
</template>
</a-setting-list-item>
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subSupportUrl"}}</template>
<template #description>{{ i18n "pages.settings.subSupportUrlDesc"}}</template>
<template #control>
<a-input type="text" v-model="allSetting.subSupportUrl" placeholder="https://example.com"></a-input>
</template>
</a-setting-list-item>
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subProfileUrl"}}</template>
<template #description>{{ i18n "pages.settings.subProfileUrlDesc"}}</template>
<template #control>
<a-input type="text" v-model="allSetting.subProfileUrl" placeholder="https://example.com"></a-input>
</template>
</a-setting-list-item>
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subAnnounce"}}</template>
<template #description>{{ i18n "pages.settings.subAnnounceDesc"}}</template>
<template #control>
<a-textarea v-model="allSetting.subAnnounce"></a-textarea>
</template>
</a-setting-list-item>
<a-divider>{{ i18n "pages.xray.advancedTemplate"}} (Happ)</a-divider>
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subEnableRouting"}}</template>
<template #description>{{ i18n "pages.settings.subEnableRoutingDesc"}}</template>
<template #control>
<a-switch v-model="allSetting.subEnableRouting"></a-switch>
</template>
</a-setting-list-item>
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subRoutingRules"}}</template>
<template #description>{{ i18n "pages.settings.subRoutingRulesDesc"}}</template>
<template #control>
<a-textarea v-model="allSetting.subRoutingRules" placeholder="happ://routing/add/..."></a-textarea>
</template>
</a-setting-list-item>
</a-collapse-panel> </a-collapse-panel>
<a-collapse-panel key="3" header='{{ i18n "pages.settings.certs" }}'> <a-collapse-panel key="3" header='{{ i18n "pages.settings.certs" }}'>
<a-setting-list-item paddings="small"> <a-setting-list-item paddings="small">

View file

@ -5,6 +5,43 @@
<script src="{{ .base_path }}assets/ant-design-vue/antd.min.js"></script> <script src="{{ .base_path }}assets/ant-design-vue/antd.min.js"></script>
<script src="{{ .base_path }}assets/js/util/index.js?{{ .cur_ver }}"></script> <script src="{{ .base_path }}assets/js/util/index.js?{{ .cur_ver }}"></script>
<script src="{{ .base_path }}assets/qrcode/qrious2.min.js?{{ .cur_ver }}"></script> <script src="{{ .base_path }}assets/qrcode/qrious2.min.js?{{ .cur_ver }}"></script>
<style>
.subscription-page .subscription-link-box {
cursor: pointer;
border-radius: 12px;
padding: 25px 20px 15px 20px;
margin-top: -12px;
word-break: break-all;
font-size: 13px;
line-height: 1.5;
text-align: left;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
transition: all 0.3s;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.dark.subscription-page .subscription-link-box {
background: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #fff;
}
.dark.subscription-page .subscription-link-box:hover {
background: rgba(0, 0, 0, 0.3);
border-color: rgba(255, 255, 255, 0.2);
}
.light.subscription-page .subscription-link-box {
background: rgba(0, 0, 0, 0.03);
border: 1px solid rgba(0, 0, 0, 0.08);
color: rgba(0, 0, 0, 0.85);
}
.light.subscription-page .subscription-link-box:hover {
background: rgba(0, 0, 0, 0.05);
border-color: rgba(0, 0, 0, 0.14);
}
</style>
{{ template "page/head_end" .}} {{ template "page/head_end" .}}
{{ template "page/body_start" .}} {{ template "page/body_start" .}}
@ -138,27 +175,12 @@
style="margin-bottom: -10px; position: relative; z-index: 2; box-shadow: 0 2px 4px rgba(0,0,0,0.2);"> style="margin-bottom: -10px; position: relative; z-index: 2; box-shadow: 0 2px 4px rgba(0,0,0,0.2);">
<span>[[ linkName(link, idx) ]]</span> <span>[[ linkName(link, idx) ]]</span>
</a-tag> </a-tag>
<div @click="copy(link)" style=" <div @click="copy(link)" class="subscription-link-box">
cursor: pointer;
background: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 25px 20px 15px 20px;
margin-top: -12px;
word-break: break-all;
color: #fff;
font-size: 13px;
line-height: 1.5;
text-align: left;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
transition: all 0.3s;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
" onmouseover="this.style.background='rgba(0, 0, 0, 0.3)'; this.style.borderColor='rgba(255, 255, 255, 0.2)'"
onmouseout="this.style.background='rgba(0, 0, 0, 0.2)'; this.style.borderColor='rgba(255, 255, 255, 0.1)'">
[[ link ]] [[ link ]]
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<br /> <br />

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
if limitIp > 0 && inbound.Enable {
shouldCleanLog = true shouldCleanLog = true
if limitIp < len(ips) { // Keep only the newest IPs (up to limitIp)
j.disAllowedIps = append(j.disAllowedIps, ips[limitIp:]...) keptIps := allIps[:limitIp]
for i := limitIp; i < len(ips); i++ { disconnectedIps := allIps[limitIp:]
log.Printf("[LIMIT_IP] Email = %s || SRC = %s", clientEmail, ips[i])
} // Log the disconnected IPs (old ones)
} for _, ipTime := range disconnectedIps {
} j.disAllowedIps = append(j.disAllowedIps, ipTime.IP)
} log.Printf("[LIMIT_IP] Email = %s || Disconnecting OLD IP = %s || Timestamp = %d", clientEmail, ipTime.IP, ipTime.Timestamp)
} }
sort.Strings(j.disAllowedIps) // Actually disconnect old IPs by temporarily removing and re-adding user
// This forces Xray to drop existing connections from old IPs
if len(disconnectedIps) > 0 {
j.disconnectClientTemporarily(inbound, clientEmail, clients)
}
if len(j.disAllowedIps) > 0 { // 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 > 25 || (major == 25 && minor > 9) || (major == 25 && minor == 9 && patch >= 11) { 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"
@ -53,6 +54,11 @@ var defaultValueMap = map[string]string{
"subEnable": "true", "subEnable": "true",
"subJsonEnable": "false", "subJsonEnable": "false",
"subTitle": "", "subTitle": "",
"subSupportUrl": "",
"subProfileUrl": "",
"subAnnounce": "",
"subEnableRouting": "true",
"subRoutingRules": "",
"subListen": "", "subListen": "",
"subPort": "2096", "subPort": "2096",
"subPath": "/sub/", "subPath": "/sub/",
@ -73,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": "",
@ -266,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")
} }
@ -459,6 +475,26 @@ func (s *SettingService) GetSubTitle() (string, error) {
return s.getString("subTitle") return s.getString("subTitle")
} }
func (s *SettingService) GetSubSupportUrl() (string, error) {
return s.getString("subSupportUrl")
}
func (s *SettingService) GetSubProfileUrl() (string, error) {
return s.getString("subProfileUrl")
}
func (s *SettingService) GetSubAnnounce() (string, error) {
return s.getString("subAnnounce")
}
func (s *SettingService) GetSubEnableRouting() (bool, error) {
return s.getBool("subEnableRouting")
}
func (s *SettingService) GetSubRoutingRules() (string, error) {
return s.getString("subRoutingRules")
}
func (s *SettingService) GetSubListen() (string, error) { func (s *SettingService) GetSubListen() (string, error) {
return s.getString("subListen") return s.getString("subListen")
} }
@ -682,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{
@ -732,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"
@ -2267,6 +2268,8 @@ func (t *Tgbot) buildSubscriptionURLs(email string) (string, string, error) {
} }
// Gather settings to construct absolute URLs // Gather settings to construct absolute URLs
subURI, _ := t.settingService.GetSubURI()
subJsonURI, _ := t.settingService.GetSubJsonURI()
subDomain, _ := t.settingService.GetSubDomain() subDomain, _ := t.settingService.GetSubDomain()
subPort, _ := t.settingService.GetSubPort() subPort, _ := t.settingService.GetSubPort()
subPath, _ := t.settingService.GetSubPath() subPath, _ := t.settingService.GetSubPath()
@ -2314,8 +2317,29 @@ func (t *Tgbot) buildSubscriptionURLs(email string) (string, string, error) {
subJsonPath = subJsonPath + "/" subJsonPath = subJsonPath + "/"
} }
subURL := fmt.Sprintf("%s://%s%s%s", scheme, host, subPath, client.SubID) var subURL string
subJsonURL := fmt.Sprintf("%s://%s%s%s", scheme, host, subJsonPath, client.SubID) var subJsonURL string
// If pre-configured URIs are available, use them directly
if subURI != "" {
if !strings.HasSuffix(subURI, "/") {
subURI = subURI + "/"
}
subURL = fmt.Sprintf("%s%s", subURI, client.SubID)
} else {
subURL = fmt.Sprintf("%s://%s%s%s", scheme, host, subPath, client.SubID)
}
if subJsonURI != "" {
if !strings.HasSuffix(subJsonURI, "/") {
subJsonURI = subJsonURI + "/"
}
subJsonURL = fmt.Sprintf("%s%s", subJsonURI, client.SubID)
} else {
subJsonURL = fmt.Sprintf("%s://%s%s%s", scheme, host, subJsonPath, client.SubID)
}
if !subJsonEnable { if !subJsonEnable {
subJsonURL = "" subJsonURL = ""
} }
@ -3060,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

@ -40,6 +40,9 @@ func (s *XrayService) GetXrayErr() error {
} }
err := p.GetErr() err := p.GetErr()
if err == nil {
return nil
}
if runtime.GOOS == "windows" && err.Error() == "exit status 1" { if runtime.GOOS == "windows" && err.Error() == "exit status 1" {
// exit status 1 on Windows means that Xray process was killed // exit status 1 on Windows means that Xray process was killed

View file

@ -374,6 +374,16 @@
"subJsonEnable" = "تمكين/تعطيل نقطة نهاية اشتراك JSON بشكل مستقل." "subJsonEnable" = "تمكين/تعطيل نقطة نهاية اشتراك JSON بشكل مستقل."
"subTitle" = "عنوان الاشتراك" "subTitle" = "عنوان الاشتراك"
"subTitleDesc" = "العنوان اللي هيظهر في عميل VPN" "subTitleDesc" = "العنوان اللي هيظهر في عميل VPN"
"subSupportUrl" = "رابط الدعم"
"subSupportUrlDesc" = "رابط الدعم الفني المعروض في عميل VPN"
"subProfileUrl" = "رابط الملف الشخصي"
"subProfileUrlDesc" = "رابط لموقعك الإلكتروني يظهر في عميل VPN"
"subAnnounce" = "إعلان"
"subAnnounceDesc" = "نص الإعلان المعروض في عميل VPN"
"subEnableRouting" = "تفعيل التوجيه"
"subEnableRoutingDesc" = "إعداد عام لتمكين التوجيه (Routing) في عميل VPN. (فقط لـ Happ)"
"subRoutingRules" = "قواعد التوجيه"
"subRoutingRulesDesc" = "قواعد التوجيه العامة لعميل VPN. (فقط لـ Happ)"
"subListen" = "IP الاستماع" "subListen" = "IP الاستماع"
"subListenDesc" = "عنوان IP لخدمة الاشتراك. (سيبه فاضي عشان يستمع على كل الـ IPs)" "subListenDesc" = "عنوان IP لخدمة الاشتراك. (سيبه فاضي عشان يستمع على كل الـ IPs)"
"subPort" = "بورت الاستماع" "subPort" = "بورت الاستماع"
@ -450,6 +460,8 @@
"FreedomStrategyDesc" = "اختار استراتيجية المخرجات للشبكة في بروتوكول الحرية." "FreedomStrategyDesc" = "اختار استراتيجية المخرجات للشبكة في بروتوكول الحرية."
"RoutingStrategy" = "استراتيجية التوجيه العامة" "RoutingStrategy" = "استراتيجية التوجيه العامة"
"RoutingStrategyDesc" = "حدد استراتيجية التوجيه الإجمالية لحل كل الطلبات." "RoutingStrategyDesc" = "حدد استراتيجية التوجيه الإجمالية لحل كل الطلبات."
"outboundTestUrl" = "رابط اختبار المخرج"
"outboundTestUrlDesc" = "الرابط المستخدم عند اختبار اتصال المخرج"
"Torrent" = "حظر بروتوكول التورنت" "Torrent" = "حظر بروتوكول التورنت"
"Inbounds" = "الإدخالات" "Inbounds" = "الإدخالات"
"InboundsDesc" = "قبول العملاء المعينين." "InboundsDesc" = "قبول العملاء المعينين."
@ -531,6 +543,12 @@
"psk" = "المفتاح المشترك" "psk" = "المفتاح المشترك"
"domainStrategy" = "استراتيجية الدومين" "domainStrategy" = "استراتيجية الدومين"
[pages.xray.tun]
"nameDesc" = "اسم واجهة TUN. القيمة الافتراضية هي 'xray0'"
"mtuDesc" = "وحدة النقل الأقصى. الحد الأقصى لحجم حزم البيانات. القيمة الافتراضية هي 1500"
"userLevel" = "مستوى المستخدم"
"userLevelDesc" = "ستستخدم جميع الاتصالات المُرسلة عبر هذا الإدخال مستوى المستخدم هذا. القيمة الافتراضية هي 0"
[pages.xray.dns] [pages.xray.dns]
"enable" = "فعل DNS" "enable" = "فعل DNS"
"enableDesc" = "فعل سيرفر DNS المدمج" "enableDesc" = "فعل سيرفر DNS المدمج"

View file

@ -374,6 +374,16 @@
"subJsonEnable" = "Enable/Disable the JSON subscription endpoint independently." "subJsonEnable" = "Enable/Disable the JSON subscription endpoint independently."
"subTitle" = "Subscription Title" "subTitle" = "Subscription Title"
"subTitleDesc" = "Title shown in VPN client" "subTitleDesc" = "Title shown in VPN client"
"subSupportUrl" = "Support URL"
"subSupportUrlDesc" = "Technical support link shown in the VPN client"
"subProfileUrl" = "Profile URL"
"subProfileUrlDesc" = "A link to your website displayed in the VPN client"
"subAnnounce" = "Announce"
"subAnnounceDesc" = "The text of the announce displayed in the VPN client"
"subEnableRouting" = "Enable routing"
"subEnableRoutingDesc" = "Global setting to enable routing in the VPN client. (Only for Happ)"
"subRoutingRules" = "Routing rules"
"subRoutingRulesDesc" = "Global routing rules for the VPN client. (Only for Happ)"
"subListen" = "Listen IP" "subListen" = "Listen IP"
"subListenDesc" = "The IP address for the subscription service. (leave blank to listen on all IPs)" "subListenDesc" = "The IP address for the subscription service. (leave blank to listen on all IPs)"
"subPort" = "Listen Port" "subPort" = "Listen Port"
@ -450,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."
@ -513,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"
@ -531,6 +549,12 @@
"psk" = "PreShared Key" "psk" = "PreShared Key"
"domainStrategy" = "Domain Strategy" "domainStrategy" = "Domain Strategy"
[pages.xray.tun]
"nameDesc" = "The name of the TUN interface. Default is 'xray0'"
"mtuDesc" = "Maximum Transmission Unit. The maximum size of data packets. Default is 1500"
"userLevel" = "User Level"
"userLevelDesc" = "All connections made through this inbound will use this user level. Default is 0"
[pages.xray.dns] [pages.xray.dns]
"enable" = "Enable DNS" "enable" = "Enable DNS"
"enableDesc" = "Enable built-in DNS server" "enableDesc" = "Enable built-in DNS server"

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"
@ -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,17 @@
"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"
"subSupportUrlDesc" = "Enlace de soporte técnico mostrado en el cliente VPN"
"subProfileUrl" = "URL del perfil"
"subProfileUrlDesc" = "Un enlace a tu sitio web mostrado en el cliente VPN"
"subAnnounce" = "Anuncio"
"subAnnounceDesc" = "El texto del anuncio mostrado en el cliente VPN"
"subEnableRouting" = "Habilitar enrutamiento"
"subEnableRoutingDesc" = "Configuración global para habilitar el enrutamiento en el cliente VPN. (Solo para Happ)"
"subRoutingRules" = "Reglas de enrutamiento"
"subRoutingRulesDesc" = "Reglas de enrutamiento globales para el cliente VPN. (Solo para Happ)"
"subListen" = "Listening IP" "subListen" = "Listening IP"
"subListenDesc" = "Dejar en blanco por defecto para monitorear todas las IPs." "subListenDesc" = "Dejar en blanco por defecto para monitorear todas las IPs."
"subPort" = "Puerto de Suscripción" "subPort" = "Puerto de Suscripción"
@ -401,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"
@ -426,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"
@ -450,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."
@ -531,6 +543,12 @@
"psk" = "Clave precompartida" "psk" = "Clave precompartida"
"domainStrategy" = "Estrategia de dominio" "domainStrategy" = "Estrategia de dominio"
[pages.xray.tun]
"nameDesc" = "El nombre de la interfaz TUN. El valor predeterminado es 'xray0'"
"mtuDesc" = "Unidad Máxima de Transmisión. El tamaño máximo de los paquetes de datos. El valor predeterminado es 1500"
"userLevel" = "Nivel de Usuario"
"userLevelDesc" = "Todas las conexiones realizadas a través de este entrada utilizarán este nivel de usuario. El valor predeterminado es 0"
[pages.xray.dns] [pages.xray.dns]
"enable" = "Habilitar DNS" "enable" = "Habilitar DNS"
"enableDesc" = "Habilitar servidor DNS incorporado" "enableDesc" = "Habilitar servidor DNS incorporado"
@ -594,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

@ -374,6 +374,16 @@
"subJsonEnable" = "فعال/غیرفعال‌سازی مستقل نقطه دسترسی سابسکریپشن JSON." "subJsonEnable" = "فعال/غیرفعال‌سازی مستقل نقطه دسترسی سابسکریپشن JSON."
"subTitle" = "عنوان اشتراک" "subTitle" = "عنوان اشتراک"
"subTitleDesc" = "عنوان نمایش داده شده در کلاینت VPN" "subTitleDesc" = "عنوان نمایش داده شده در کلاینت VPN"
"subSupportUrl" = "آدرس پشتیبانی"
"subSupportUrlDesc" = "لینک پشتیبانی فنی که در کلاینت VPN نمایش داده می‌شود"
"subProfileUrl" = "آدرس پروفایل"
"subProfileUrlDesc" = "لینک وب‌سایت شما که در کلاینت VPN نمایش داده می‌شود"
"subAnnounce" = "اعلان"
"subAnnounceDesc" = "متن اعلانی که در کلاینت VPN نمایش داده می‌شود"
"subEnableRouting" = "فعال‌سازی مسیریابی"
"subEnableRoutingDesc" = "تنظیمات سراسری برای فعال‌سازی مسیریابی در کلاینت VPN. (فقط برای Happ)"
"subRoutingRules" = "قوانین مسیریابی"
"subRoutingRulesDesc" = "قوانین مسیریابی سراسری برای کلاینت VPN. (فقط برای Happ)"
"subListen" = "آدرس آی‌پی" "subListen" = "آدرس آی‌پی"
"subListenDesc" = "آدرس آی‌پی برای سرویس سابسکریپشن. برای گوش دادن به‌تمام آی‌پی‌ها خالی‌بگذارید" "subListenDesc" = "آدرس آی‌پی برای سرویس سابسکریپشن. برای گوش دادن به‌تمام آی‌پی‌ها خالی‌بگذارید"
"subPort" = "پورت" "subPort" = "پورت"
@ -450,6 +460,8 @@
"FreedomStrategyDesc" = "تعیین می‌کند Freedom استراتژی خروجی شبکه را برای پروتکل" "FreedomStrategyDesc" = "تعیین می‌کند Freedom استراتژی خروجی شبکه را برای پروتکل"
"RoutingStrategy" = "استراتژی کلی مسیریابی" "RoutingStrategy" = "استراتژی کلی مسیریابی"
"RoutingStrategyDesc" = "استراتژی کلی مسیریابی برای حل تمام درخواست‌ها را تعیین می‌کند" "RoutingStrategyDesc" = "استراتژی کلی مسیریابی برای حل تمام درخواست‌ها را تعیین می‌کند"
"outboundTestUrl" = "آدرس تست خروجی"
"outboundTestUrlDesc" = "آدرسی که برای تست اتصال خروجی استفاده می‌شود."
"Torrent" = "مسدودسازی پروتکل بیت‌تورنت" "Torrent" = "مسدودسازی پروتکل بیت‌تورنت"
"Inbounds" = "ورودی‌ها" "Inbounds" = "ورودی‌ها"
"InboundsDesc" = "پذیرش کلاینت خاص" "InboundsDesc" = "پذیرش کلاینت خاص"
@ -531,6 +543,12 @@
"psk" = "کلید مشترک" "psk" = "کلید مشترک"
"domainStrategy" = "استراتژی حل دامنه" "domainStrategy" = "استراتژی حل دامنه"
[pages.xray.tun]
"nameDesc" = "نام رابط TUN. مقدار پیش‌فرض 'xray0' است"
"mtuDesc" = "واحد انتقال حداکثر. بیشترین اندازه بسته‌های داده. مقدار پیش‌فرض 1500 است"
"userLevel" = "سطح کاربر"
"userLevelDesc" = "تمام اتصالات انجام‌شده از طریق این ورودی از این سطح کاربری استفاده خواهند کرد. مقدار پیش‌فرض 0 است"
[pages.xray.dns] [pages.xray.dns]
"enable" = "فعال کردن حل دامنه" "enable" = "فعال کردن حل دامنه"
"enableDesc" = "سرور حل دامنه داخلی را فعال کنید" "enableDesc" = "سرور حل دامنه داخلی را فعال کنید"

View file

@ -374,6 +374,16 @@
"subJsonEnable" = "Aktifkan/Nonaktifkan endpoint langganan JSON secara mandiri." "subJsonEnable" = "Aktifkan/Nonaktifkan endpoint langganan JSON secara mandiri."
"subTitle" = "Judul Langganan" "subTitle" = "Judul Langganan"
"subTitleDesc" = "Judul yang ditampilkan di klien VPN" "subTitleDesc" = "Judul yang ditampilkan di klien VPN"
"subSupportUrl" = "URL Dukungan"
"subSupportUrlDesc" = "Tautan dukungan teknis yang ditampilkan di klien VPN"
"subProfileUrl" = "URL Profil"
"subProfileUrlDesc" = "Tautan ke situs web Anda yang ditampilkan di klien VPN"
"subAnnounce" = "Pengumuman"
"subAnnounceDesc" = "Teks pengumuman yang ditampilkan di klien VPN"
"subEnableRouting" = "Aktifkan perutean"
"subEnableRoutingDesc" = "Pengaturan global untuk mengaktifkan perutean (routing) di klien VPN. (Hanya untuk Happ)"
"subRoutingRules" = "Aturan routing"
"subRoutingRulesDesc" = "Aturan routing global untuk klien VPN. (Hanya untuk Happ)"
"subListen" = "IP Pendengar" "subListen" = "IP Pendengar"
"subListenDesc" = "Alamat IP untuk layanan langganan. (biarkan kosong untuk mendengarkan semua IP)" "subListenDesc" = "Alamat IP untuk layanan langganan. (biarkan kosong untuk mendengarkan semua IP)"
"subPort" = "Port Pendengar" "subPort" = "Port Pendengar"
@ -450,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."
@ -531,6 +543,12 @@
"psk" = "Kunci Pra-Bagi" "psk" = "Kunci Pra-Bagi"
"domainStrategy" = "Strategi Domain" "domainStrategy" = "Strategi Domain"
[pages.xray.tun]
"nameDesc" = "Nama antarmuka TUN. Standar adalah 'xray0'"
"mtuDesc" = "Unit Transmisi Maksimum. Ukuran maksimum paket data. Standar adalah 1500"
"userLevel" = "Level Pengguna"
"userLevelDesc" = "Semua koneksi yang dibuat melalui inbound ini akan menggunakan level pengguna ini. Standar adalah 0"
[pages.xray.dns] [pages.xray.dns]
"enable" = "Aktifkan DNS" "enable" = "Aktifkan DNS"
"enableDesc" = "Aktifkan server DNS bawaan" "enableDesc" = "Aktifkan server DNS bawaan"

View file

@ -374,6 +374,16 @@
"subJsonEnable" = "JSON サブスクリプションのエンドポイントを個別に有効/無効にする。" "subJsonEnable" = "JSON サブスクリプションのエンドポイントを個別に有効/無効にする。"
"subTitle" = "サブスクリプションタイトル" "subTitle" = "サブスクリプションタイトル"
"subTitleDesc" = "VPNクライアントに表示されるタイトル" "subTitleDesc" = "VPNクライアントに表示されるタイトル"
"subSupportUrl" = "サポートURL"
"subSupportUrlDesc" = "VPNクライアントに表示されるテクニカルサポートへのリンク"
"subProfileUrl" = "プロフィールURL"
"subProfileUrlDesc" = "VPNクライアントに表示されるWebサイトへのリンク"
"subAnnounce" = "お知らせ"
"subAnnounceDesc" = "VPNクライアントに表示されるお知らせのテキスト"
"subEnableRouting" = "ルーティングを有効化"
"subEnableRoutingDesc" = "VPNクライアントでルーティングを有効にするためのグローバル設定。(Happのみ)"
"subRoutingRules" = "ルーティングルール"
"subRoutingRulesDesc" = "VPNクライアントのグローバルルーティングルール。(Happのみ)"
"subListen" = "監視IP" "subListen" = "監視IP"
"subListenDesc" = "サブスクリプションサービスが監視するIPアドレス空白にするとすべてのIPを監視" "subListenDesc" = "サブスクリプションサービスが監視するIPアドレス空白にするとすべてのIPを監視"
"subPort" = "監視ポート" "subPort" = "監視ポート"
@ -450,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" = "特定のクライアントからのトラフィックを受け入れる"
@ -531,6 +543,12 @@
"psk" = "共有キー" "psk" = "共有キー"
"domainStrategy" = "ドメイン戦略" "domainStrategy" = "ドメイン戦略"
[pages.xray.tun]
"nameDesc" = "TUN インターフェースの名前。デフォルトは 'xray0' です"
"mtuDesc" = "最大伝送単位。データパケットの最大サイズ。デフォルトは 1500 です"
"userLevel" = "ユーザーレベル"
"userLevelDesc" = "このインバウンドを通じて確立されたすべての接続は、このユーザーレベルを使用します。デフォルトは 0 です"
[pages.xray.dns] [pages.xray.dns]
"enable" = "DNSを有効にする" "enable" = "DNSを有効にする"
"enableDesc" = "組み込みDNSサーバーを有効にする" "enableDesc" = "組み込みDNSサーバーを有効にする"

View file

@ -374,6 +374,16 @@
"subJsonEnable" = "Ativar/Desativar o endpoint de assinatura JSON de forma independente." "subJsonEnable" = "Ativar/Desativar o endpoint de assinatura JSON de forma independente."
"subTitle" = "Título da Assinatura" "subTitle" = "Título da Assinatura"
"subTitleDesc" = "Título exibido no cliente VPN" "subTitleDesc" = "Título exibido no cliente VPN"
"subSupportUrl" = "URL de Suporte"
"subSupportUrlDesc" = "Link de suporte técnico exibido no cliente VPN"
"subProfileUrl" = "URL de Perfil"
"subProfileUrlDesc" = "Um link para o seu site exibido no cliente VPN"
"subAnnounce" = "Anúncio"
"subAnnounceDesc" = "O texto do anúncio exibido no cliente VPN"
"subEnableRouting" = "Ativar roteamento"
"subEnableRoutingDesc" = "Configuração global para habilitar o roteamento no cliente VPN. (Apenas para Happ)"
"subRoutingRules" = "Regras de roteamento"
"subRoutingRulesDesc" = "Regras de roteamento globais para o cliente VPN. (Apenas para Happ)"
"subListen" = "IP de Escuta" "subListen" = "IP de Escuta"
"subListenDesc" = "O endereço IP para o serviço de assinatura. (deixe em branco para escutar em todos os IPs)" "subListenDesc" = "O endereço IP para o serviço de assinatura. (deixe em branco para escutar em todos os IPs)"
"subPort" = "Porta de Escuta" "subPort" = "Porta de Escuta"
@ -450,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."
@ -531,6 +543,12 @@
"psk" = "Chave Pré-Compartilhada" "psk" = "Chave Pré-Compartilhada"
"domainStrategy" = "Estratégia de Domínio" "domainStrategy" = "Estratégia de Domínio"
[pages.xray.tun]
"nameDesc" = "O nome da interface TUN. O padrão é 'xray0'"
"mtuDesc" = "Unidade Máxima de Transmissão. O tamanho máximo dos pacotes de dados. O padrão é 1500"
"userLevel" = "Nível do Usuário"
"userLevelDesc" = "Todas as conexões feitas através deste inbound usarão este nível de usuário. O padrão é 0"
[pages.xray.dns] [pages.xray.dns]
"enable" = "Ativar DNS" "enable" = "Ativar DNS"
"enableDesc" = "Ativar o servidor DNS integrado" "enableDesc" = "Ativar o servidor DNS integrado"

View file

@ -373,7 +373,17 @@
"subEnableDesc" = "Функция подписки с отдельной конфигурацией" "subEnableDesc" = "Функция подписки с отдельной конфигурацией"
"subJsonEnable" = "Включить/отключить JSON-эндпоинт подписки независимо." "subJsonEnable" = "Включить/отключить JSON-эндпоинт подписки независимо."
"subTitle" = "Заголовок подписки" "subTitle" = "Заголовок подписки"
"subTitleDesc" = "Название подписки, которое видит клиент в VPN клиенте" "subTitleDesc" = "Название подписки, которое видит клиент в VPN-клиенте"
"subSupportUrl" = "URL поддержки"
"subSupportUrlDesc" = "Ссылка на техническую поддержку, отображаемая в VPN-клиенте"
"subProfileUrl" = "URL профиля"
"subProfileUrlDesc" = "Ссылка на ваш сайт, отображаемая в VPN-клиенте"
"subAnnounce" = "Объявление"
"subAnnounceDesc" = "Текст объявления, отображаемый в VPN-клиенте"
"subEnableRouting" = "Включить маршрутизацию"
"subEnableRoutingDesc" = "Глобальная настройка для включения маршрутизации в VPN-клиенте. (Только для Happ)"
"subRoutingRules" = "Правила маршрутизации"
"subRoutingRulesDesc" = "Глобальные правила маршрутизации для VPN-клиента. (Только для Happ)"
"subListen" = "Прослушивание IP" "subListen" = "Прослушивание IP"
"subListenDesc" = "Оставьте пустым по умолчанию, чтобы отслеживать все IP-адреса" "subListenDesc" = "Оставьте пустым по умолчанию, чтобы отслеживать все IP-адреса"
"subPort" = "Порт подписки" "subPort" = "Порт подписки"
@ -450,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" = "Изменение шаблона конфигурации для подключения определенных клиентов"
@ -531,6 +543,12 @@
"psk" = "Общий ключ" "psk" = "Общий ключ"
"domainStrategy" = "Стратегия домена" "domainStrategy" = "Стратегия домена"
[pages.xray.tun]
"nameDesc" = "Имя интерфейса TUN. Значение по умолчанию - 'xray0'"
"mtuDesc" = "Максимальная единица передачи. Максимальный размер пакетов данных. Значение по умолчанию - 1500"
"userLevel" = "Уровень пользователя"
"userLevelDesc" = "Все соединения, установленные через этот входящий поток, будут использовать этот уровень пользователя. Значение по умолчанию - 0"
[pages.xray.dns] [pages.xray.dns]
"enable" = "Включить DNS" "enable" = "Включить DNS"
"enableDesc" = "Включить встроенный DNS-сервер" "enableDesc" = "Включить встроенный DNS-сервер"

View file

@ -374,6 +374,16 @@
"subJsonEnable" = "JSON abonelik uç noktasını bağımsız olarak Etkinleştir/Devre Dışı bırak." "subJsonEnable" = "JSON abonelik uç noktasını bağımsız olarak Etkinleştir/Devre Dışı bırak."
"subTitle" = "Abonelik Başlığı" "subTitle" = "Abonelik Başlığı"
"subTitleDesc" = "VPN istemcisinde gösterilen başlık" "subTitleDesc" = "VPN istemcisinde gösterilen başlık"
"subSupportUrl" = "Destek URL'si"
"subSupportUrlDesc" = "VPN istemcisinde gösterilen teknik destek bağlantısı"
"subProfileUrl" = "Profil URL'si"
"subProfileUrlDesc" = "VPN istemcisinde görüntülenen web sitenize giden bağlantı"
"subAnnounce" = "Duyuru"
"subAnnounceDesc" = "VPN istemcisinde görüntülenen duyuru metni"
"subEnableRouting" = "Yönlendirmeyi etkinleştir"
"subEnableRoutingDesc" = "VPN istemcisinde yönlendirmeyi etkinleştirmek için genel ayar. (Yalnızca Happ için)"
"subRoutingRules" = "Yönlendirme kuralları"
"subRoutingRulesDesc" = "VPN istemcisi için genel yönlendirme kuralları. (Yalnızca Happ için)"
"subListen" = "Dinleme IP" "subListen" = "Dinleme IP"
"subListenDesc" = "Abonelik hizmeti için IP adresi. (tüm IP'leri dinlemek için boş bırakın)" "subListenDesc" = "Abonelik hizmeti için IP adresi. (tüm IP'leri dinlemek için boş bırakın)"
"subPort" = "Dinleme Portu" "subPort" = "Dinleme Portu"
@ -450,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."
@ -531,6 +543,12 @@
"psk" = "Ön Paylaşılan Anahtar" "psk" = "Ön Paylaşılan Anahtar"
"domainStrategy" = "Alan Adı Stratejisi" "domainStrategy" = "Alan Adı Stratejisi"
[pages.xray.tun]
"nameDesc" = "TUN arabiriminin adı. Varsayılan değer 'xray0'dir"
"mtuDesc" = "Maksimum İletim Birimi. Veri paketlerinin maksimum boyutu. Varsayılan değer 1500'dür"
"userLevel" = "Kullanıcı Seviyesi"
"userLevelDesc" = "Bu giriş yoluyla yapılan tüm bağlantılar bu kullanıcı seviyesini kullanacaktır. Varsayılan değer 0'dır"
[pages.xray.dns] [pages.xray.dns]
"enable" = "DNS'yi Etkinleştir" "enable" = "DNS'yi Etkinleştir"
"enableDesc" = "Dahili DNS sunucusunu etkinleştir" "enableDesc" = "Dahili DNS sunucusunu etkinleştir"

View file

@ -374,6 +374,16 @@
"subJsonEnable" = "Увімкнути/вимкнути JSON-кінець підписки незалежно." "subJsonEnable" = "Увімкнути/вимкнути JSON-кінець підписки незалежно."
"subTitle" = "Назва Підписки" "subTitle" = "Назва Підписки"
"subTitleDesc" = "Назва, яка відображається у VPN-клієнті" "subTitleDesc" = "Назва, яка відображається у VPN-клієнті"
"subSupportUrl" = "URL підтримки"
"subSupportUrlDesc" = "Посилання на технічну підтримку, що відображається у VPN-клієнті"
"subProfileUrl" = "URL профілю"
"subProfileUrlDesc" = "Посилання на ваш вебсайт, що відображається у VPN-клієнті"
"subAnnounce" = "Оголошення"
"subAnnounceDesc" = "Текст оголошення, що відображається у VPN-клієнті"
"subEnableRouting" = "Увімкнути маршрутизацію"
"subEnableRoutingDesc" = "Глобальне налаштування для увімкнення маршрутизації у VPN-клієнті. (Тільки для Happ)"
"subRoutingRules" = "Правила маршрутизації"
"subRoutingRulesDesc" = "Глобальні правила маршрутизації для VPN-клієнта. (Тільки для Happ)"
"subListen" = "Слухати IP" "subListen" = "Слухати IP"
"subListenDesc" = "IP-адреса для служби підписки. (залиште порожнім, щоб слухати всі IP-адреси)" "subListenDesc" = "IP-адреса для служби підписки. (залиште порожнім, щоб слухати всі IP-адреси)"
"subPort" = "Слухати порт" "subPort" = "Слухати порт"
@ -450,6 +460,8 @@
"FreedomStrategyDesc" = "Установити стратегію виведення для мережі в протоколі свободи." "FreedomStrategyDesc" = "Установити стратегію виведення для мережі в протоколі свободи."
"RoutingStrategy" = "Загальна стратегія маршрутизації" "RoutingStrategy" = "Загальна стратегія маршрутизації"
"RoutingStrategyDesc" = "Установити загальну стратегію маршрутизації трафіку для вирішення всіх запитів." "RoutingStrategyDesc" = "Установити загальну стратегію маршрутизації трафіку для вирішення всіх запитів."
"outboundTestUrl" = "URL тесту outbound"
"outboundTestUrlDesc" = "URL для перевірки з'єднання outbound"
"Torrent" = "Блокувати протокол BitTorrent" "Torrent" = "Блокувати протокол BitTorrent"
"Inbounds" = "Вхідні" "Inbounds" = "Вхідні"
"InboundsDesc" = "Прийняття певних клієнтів." "InboundsDesc" = "Прийняття певних клієнтів."
@ -531,6 +543,12 @@
"psk" = "Спільний ключ" "psk" = "Спільний ключ"
"domainStrategy" = "Стратегія домену" "domainStrategy" = "Стратегія домену"
[pages.xray.tun]
"nameDesc" = "Назва інтерфейсу TUN. Значення за замовчуванням - 'xray0'"
"mtuDesc" = "Максимальна одиниця передачі. Максимальний розмір пакетів даних. Значення за замовчуванням - 1500"
"userLevel" = "Рівень користувача"
"userLevelDesc" = "Всі з'єднання, встановлені через цей вхід, використовуватимуть цей рівень користувача. Значення за замовчуванням - 0"
[pages.xray.dns] [pages.xray.dns]
"enable" = "Увімкнути DNS" "enable" = "Увімкнути DNS"
"enableDesc" = "Увімкнути вбудований DNS-сервер" "enableDesc" = "Увімкнути вбудований DNS-сервер"

View file

@ -374,6 +374,16 @@
"subJsonEnable" = "Bật/Tắt điểm cuối đăng ký JSON độc lập." "subJsonEnable" = "Bật/Tắt điểm cuối đăng ký JSON độc lập."
"subTitle" = "Tiêu đề Đăng ký" "subTitle" = "Tiêu đề Đăng ký"
"subTitleDesc" = "Tiêu đề hiển thị trong ứng dụng VPN" "subTitleDesc" = "Tiêu đề hiển thị trong ứng dụng VPN"
"subSupportUrl" = "URL Hỗ trợ"
"subSupportUrlDesc" = "Liên kết hỗ trợ kỹ thuật hiển thị trong ứng dụng VPN"
"subProfileUrl" = "URL Hồ sơ"
"subProfileUrlDesc" = "Liên kết đến trang web của bạn hiển thị trong ứng dụng VPN"
"subAnnounce" = "Thông báo"
"subAnnounceDesc" = "Văn bản thông báo hiển thị trong ứng dụng VPN"
"subEnableRouting" = "Bật định tuyến"
"subEnableRoutingDesc" = "Cài đặt toàn cục để bật định tuyến trong ứng dụng khách VPN. (Chỉ dành cho Happ)"
"subRoutingRules" = "Quy tắc định tuyến"
"subRoutingRulesDesc" = "Quy tắc định tuyến toàn cầu cho client VPN. (Chỉ dành cho Happ)"
"subListen" = "Listening IP" "subListen" = "Listening IP"
"subListenDesc" = "Mặc định để trống để nghe tất cả các IP" "subListenDesc" = "Mặc định để trống để nghe tất cả các IP"
"subPort" = "Cổng gói đăng ký" "subPort" = "Cổng gói đăng ký"
@ -450,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ể."
@ -531,6 +543,12 @@
"psk" = "Khóa chia sẻ" "psk" = "Khóa chia sẻ"
"domainStrategy" = "Chiến lược tên miền" "domainStrategy" = "Chiến lược tên miền"
[pages.xray.tun]
"nameDesc" = "Tên của giao diện TUN. Giá trị mặc định là 'xray0'"
"mtuDesc" = "Đơn vị Truyền Tối đa. Kích thước tối đa của các gói dữ liệu. Giá trị mặc định là 1500"
"userLevel" = "Mức Người Dùng"
"userLevelDesc" = "Tất cả các kết nối được thực hiện thông qua inbound này sẽ sử dụng mức người dùng này. Giá trị mặc định là 0"
[pages.xray.dns] [pages.xray.dns]
"enable" = "Kích hoạt DNS" "enable" = "Kích hoạt DNS"
"enableDesc" = "Kích hoạt máy chủ DNS tích hợp" "enableDesc" = "Kích hoạt máy chủ DNS tích hợp"

View file

@ -374,6 +374,16 @@
"subJsonEnable" = "单独启用/禁用 JSON 订阅端点。" "subJsonEnable" = "单独启用/禁用 JSON 订阅端点。"
"subTitle" = "订阅标题" "subTitle" = "订阅标题"
"subTitleDesc" = "在VPN客户端中显示的标题" "subTitleDesc" = "在VPN客户端中显示的标题"
"subSupportUrl" = "支持链接"
"subSupportUrlDesc" = "VPN 客户端中显示的技术支持链接"
"subProfileUrl" = "个人资料链接"
"subProfileUrlDesc" = "VPN 客户端中显示的网站链接"
"subAnnounce" = "公告"
"subAnnounceDesc" = "VPN 客户端中显示的公告文本"
"subEnableRouting" = "启用路由"
"subEnableRoutingDesc" = "在 VPN 客户端中启用路由的全局设置。(僅限 Happ"
"subRoutingRules" = "路由規則"
"subRoutingRulesDesc" = "VPN 用戶端的全域路由規則。(僅限 Happ"
"subListen" = "监听 IP" "subListen" = "监听 IP"
"subListenDesc" = "订阅服务监听的 IP 地址(留空表示监听所有 IP" "subListenDesc" = "订阅服务监听的 IP 地址(留空表示监听所有 IP"
"subPort" = "监听端口" "subPort" = "监听端口"
@ -450,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" = "接受来自特定客户端的流量"
@ -531,6 +543,12 @@
"psk" = "共享密钥" "psk" = "共享密钥"
"domainStrategy" = "域策略" "domainStrategy" = "域策略"
[pages.xray.tun]
"nameDesc" = "TUN 接口的名称。默认值为 'xray0'"
"mtuDesc" = "最大传输单元。数据包的最大大小。默认值为 1500"
"userLevel" = "用户级别"
"userLevelDesc" = "通过此入站的所有连接都将使用此用户级别。默认值为 0"
[pages.xray.dns] [pages.xray.dns]
"enable" = "启用 DNS" "enable" = "启用 DNS"
"enableDesc" = "启用内置 DNS 服务器" "enableDesc" = "启用内置 DNS 服务器"

View file

@ -374,6 +374,16 @@
"subJsonEnable" = "獨立啟用/停用 JSON 訂閱端點。" "subJsonEnable" = "獨立啟用/停用 JSON 訂閱端點。"
"subTitle" = "訂閱標題" "subTitle" = "訂閱標題"
"subTitleDesc" = "在VPN客戶端中顯示的標題" "subTitleDesc" = "在VPN客戶端中顯示的標題"
"subSupportUrl" = "支援連結"
"subSupportUrlDesc" = "VPN 用戶端中顯示的技術支援連結"
"subProfileUrl" = "個人資料連結"
"subProfileUrlDesc" = "VPN 用戶端中顯示的網站連結"
"subAnnounce" = "公告"
"subAnnounceDesc" = "VPN 用戶端中顯示的公告文字"
"subEnableRouting" = "啟用路由"
"subEnableRoutingDesc" = "在 VPN 用戶端中啟用路由的全域設定。(僅限 Happ"
"subRoutingRules" = "路由規則"
"subRoutingRulesDesc" = "VPN 用戶端的全域路由規則。(僅限 Happ"
"subListen" = "監聽 IP" "subListen" = "監聽 IP"
"subListenDesc" = "訂閱服務監聽的 IP 地址(留空表示監聽所有 IP" "subListenDesc" = "訂閱服務監聽的 IP 地址(留空表示監聽所有 IP"
"subPort" = "監聽埠" "subPort" = "監聽埠"
@ -450,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" = "接受來自特定客戶端的流量"
@ -531,6 +543,12 @@
"psk" = "共享金鑰" "psk" = "共享金鑰"
"domainStrategy" = "域策略" "domainStrategy" = "域策略"
[pages.xray.tun]
"nameDesc" = "TUN 介面的名稱。預設值為 'xray0'"
"mtuDesc" = "最大傳輸單元。資料包的最大大小。預設值為 1500"
"userLevel" = "用戶級別"
"userLevelDesc" = "通過此入站的所有連接都將使用此用戶級別。預設值為 0"
[pages.xray.dns] [pages.xray.dns]
"enable" = "啟用 DNS" "enable" = "啟用 DNS"
"enableDesc" = "啟用內建 DNS 伺服器" "enableDesc" = "啟用內建 DNS 伺服器"

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)

16
x-ui.service.arch Normal file
View file

@ -0,0 +1,16 @@
[Unit]
Description=x-ui Service
After=network.target
Wants=network.target
[Service]
EnvironmentFile=-/etc/conf.d/x-ui
Environment="XRAY_VMESS_AEAD_FORCED=false"
Type=simple
WorkingDirectory=/usr/lib/x-ui/
ExecStart=/usr/lib/x-ui/x-ui
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=multi-user.target

182
x-ui.sh
View file

@ -19,6 +19,23 @@ function LOGI() {
echo -e "${green}[INF] $* ${plain}" echo -e "${green}[INF] $* ${plain}"
} }
# Port helpers: detect listener and owning process (best effort)
is_port_in_use() {
local port="$1"
if command -v ss >/dev/null 2>&1; then
ss -ltn 2>/dev/null | awk -v p=":${port}$" '$4 ~ p {exit 0} END {exit 1}'
return
fi
if command -v netstat >/dev/null 2>&1; then
netstat -lnt 2>/dev/null | awk -v p=":${port} " '$4 ~ p {exit 0} END {exit 1}'
return
fi
if command -v lsof >/dev/null 2>&1; then
lsof -nP -iTCP:${port} -sTCP:LISTEN >/dev/null 2>&1 && return 0
fi
return 1
}
# Simple helpers for domain/IP validation # Simple helpers for domain/IP validation
is_ipv4() { is_ipv4() {
[[ "$1" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] && return 0 || return 1 [[ "$1" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] && return 0 || return 1
@ -30,7 +47,7 @@ is_ip() {
is_ipv4 "$1" || is_ipv6 "$1" is_ipv4 "$1" || is_ipv6 "$1"
} }
is_domain() { is_domain() {
[[ "$1" =~ ^([A-Za-z0-9](-*[A-Za-z0-9])*\.)+[A-Za-z]{2,}$ ]] && return 0 || return 1 [[ "$1" =~ ^([A-Za-z0-9](-*[A-Za-z0-9])*\.)+(xn--[a-z0-9]{2,}|[A-Za-z]{2,})$ ]] && return 0 || return 1
} }
# check root # check root
@ -212,9 +229,9 @@ reset_user() {
read -rp "Do you want to disable currently configured two-factor authentication? (y/n): " twoFactorConfirm read -rp "Do you want to disable currently configured two-factor authentication? (y/n): " twoFactorConfirm
if [[ $twoFactorConfirm != "y" && $twoFactorConfirm != "Y" ]]; then if [[ $twoFactorConfirm != "y" && $twoFactorConfirm != "Y" ]]; then
${xui_folder}/x-ui setting -username ${config_account} -password ${config_password} -resetTwoFactor false >/dev/null 2>&1 ${xui_folder}/x-ui setting -username "${config_account}" -password "${config_password}" -resetTwoFactor false >/dev/null 2>&1
else else
${xui_folder}/x-ui setting -username ${config_account} -password ${config_password} -resetTwoFactor true >/dev/null 2>&1 ${xui_folder}/x-ui setting -username "${config_account}" -password "${config_password}" -resetTwoFactor true >/dev/null 2>&1
echo -e "Two factor authentication has been disabled." echo -e "Two factor authentication has been disabled."
fi fi
@ -513,20 +530,27 @@ bbr_menu() {
disable_bbr() { disable_bbr() {
if ! grep -q "net.core.default_qdisc=fq" /etc/sysctl.conf || ! grep -q "net.ipv4.tcp_congestion_control=bbr" /etc/sysctl.conf; then if [[ $(sysctl -n net.ipv4.tcp_congestion_control) != "bbr" ]] || [[ ! $(sysctl -n net.core.default_qdisc) =~ ^(fq|cake)$ ]]; then
echo -e "${yellow}BBR is not currently enabled.${plain}" echo -e "${yellow}BBR is not currently enabled.${plain}"
before_show_menu before_show_menu
fi fi
if [ -f "/etc/sysctl.d/99-bbr-x-ui.conf" ]; then
old_settings=$(head -1 /etc/sysctl.d/99-bbr-x-ui.conf | tr -d '#')
sysctl -w net.core.default_qdisc="${old_settings%:*}"
sysctl -w net.ipv4.tcp_congestion_control="${old_settings#*:}"
rm /etc/sysctl.d/99-bbr-x-ui.conf
sysctl --system
else
# Replace BBR with CUBIC configurations # Replace BBR with CUBIC configurations
if [ -f "/etc/sysctl.conf" ]; then
sed -i 's/net.core.default_qdisc=fq/net.core.default_qdisc=pfifo_fast/' /etc/sysctl.conf sed -i 's/net.core.default_qdisc=fq/net.core.default_qdisc=pfifo_fast/' /etc/sysctl.conf
sed -i 's/net.ipv4.tcp_congestion_control=bbr/net.ipv4.tcp_congestion_control=cubic/' /etc/sysctl.conf sed -i 's/net.ipv4.tcp_congestion_control=bbr/net.ipv4.tcp_congestion_control=cubic/' /etc/sysctl.conf
# Apply changes
sysctl -p sysctl -p
fi
fi
# Verify that BBR is replaced with CUBIC if [[ $(sysctl -n net.ipv4.tcp_congestion_control) != "bbr" ]]; then
if [[ $(sysctl net.ipv4.tcp_congestion_control | awk '{print $3}') == "cubic" ]]; then
echo -e "${green}BBR has been replaced with CUBIC successfully.${plain}" echo -e "${green}BBR has been replaced with CUBIC successfully.${plain}"
else else
echo -e "${red}Failed to replace BBR with CUBIC. Please check your system configuration.${plain}" echo -e "${red}Failed to replace BBR with CUBIC. Please check your system configuration.${plain}"
@ -534,50 +558,34 @@ disable_bbr() {
} }
enable_bbr() { enable_bbr() {
if grep -q "net.core.default_qdisc=fq" /etc/sysctl.conf && grep -q "net.ipv4.tcp_congestion_control=bbr" /etc/sysctl.conf; then if [[ $(sysctl -n net.ipv4.tcp_congestion_control) == "bbr" ]] && [[ $(sysctl -n net.core.default_qdisc) =~ ^(fq|cake)$ ]]; then
echo -e "${green}BBR is already enabled!${plain}" echo -e "${green}BBR is already enabled!${plain}"
before_show_menu before_show_menu
fi fi
# Check the OS and install necessary packages
case "${release}" in
ubuntu | debian | armbian)
apt-get update && apt-get install -yqq --no-install-recommends ca-certificates
;;
fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol)
dnf -y update && dnf -y install ca-certificates
;;
centos)
if [[ "${VERSION_ID}" =~ ^7 ]]; then
yum -y update && yum -y install ca-certificates
else
dnf -y update && dnf -y install ca-certificates
fi
;;
arch | manjaro | parch)
pacman -Sy --noconfirm ca-certificates
;;
opensuse-tumbleweed | opensuse-leap)
zypper refresh && zypper -q install -y ca-certificates
;;
alpine)
apk add ca-certificates
;;
*)
echo -e "${red}Unsupported operating system. Please check the script and install the necessary packages manually.${plain}\n"
exit 1
;;
esac
# Enable BBR # Enable BBR
if [ -d "/etc/sysctl.d/" ]; then
{
echo "#$(sysctl -n net.core.default_qdisc):$(sysctl -n net.ipv4.tcp_congestion_control)"
echo "net.core.default_qdisc = fq"
echo "net.ipv4.tcp_congestion_control = bbr"
} > "/etc/sysctl.d/99-bbr-x-ui.conf"
if [ -f "/etc/sysctl.conf" ]; then
# Backup old settings from sysctl.conf, if any
sed -i 's/^net.core.default_qdisc/# &/' /etc/sysctl.conf
sed -i 's/^net.ipv4.tcp_congestion_control/# &/' /etc/sysctl.conf
fi
sysctl --system
else
sed -i '/net.core.default_qdisc/d' /etc/sysctl.conf
sed -i '/net.ipv4.tcp_congestion_control/d' /etc/sysctl.conf
echo "net.core.default_qdisc=fq" | tee -a /etc/sysctl.conf echo "net.core.default_qdisc=fq" | tee -a /etc/sysctl.conf
echo "net.ipv4.tcp_congestion_control=bbr" | tee -a /etc/sysctl.conf echo "net.ipv4.tcp_congestion_control=bbr" | tee -a /etc/sysctl.conf
# Apply changes
sysctl -p sysctl -p
fi
# Verify that BBR is enabled # Verify that BBR is enabled
if [[ $(sysctl net.ipv4.tcp_congestion_control | awk '{print $3}') == "bbr" ]]; then if [[ $(sysctl -n net.ipv4.tcp_congestion_control) == "bbr" ]]; then
echo -e "${green}BBR has been enabled successfully.${plain}" echo -e "${green}BBR has been enabled successfully.${plain}"
else else
echo -e "${red}Failed to enable BBR. Please check your system configuration.${plain}" echo -e "${red}Failed to enable BBR. Please check your system configuration.${plain}"
@ -903,24 +911,23 @@ delete_ports() {
} }
update_all_geofiles() { update_all_geofiles() {
update_main_geofiles update_geofiles "main"
update_ir_geofiles update_geofiles "IR"
update_ru_geofiles update_geofiles "RU"
} }
update_main_geofiles() { update_geofiles() {
curl -fLRo geoip.dat https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat case "${1}" in
curl -fLRo geosite.dat https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat "main") dat_files=(geoip geosite); dat_source="Loyalsoldier/v2ray-rules-dat";;
} "IR") dat_files=(geoip_IR geosite_IR); dat_source="chocolate4u/Iran-v2ray-rules" ;;
"RU") dat_files=(geoip_RU geosite_RU); dat_source="runetfreedom/russia-v2ray-rules-dat";;
update_ir_geofiles() { esac
curl -fLRo geoip_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat for dat in "${dat_files[@]}"; do
curl -fLRo geosite_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat # Remove suffix for remote filename (e.g., geoip_IR -> geoip)
} remote_file="${dat%%_*}"
curl -fLRo ${xui_folder}/bin/${dat}.dat -z ${xui_folder}/bin/${dat}.dat \
update_ru_geofiles() { https://github.com/${dat_source}/releases/latest/download/${remote_file}.dat
curl -fLRo geoip_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat done
curl -fLRo geosite_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat
} }
update_geo() { update_geo() {
@ -931,24 +938,22 @@ update_geo() {
echo -e "${green}\t0.${plain} Back to Main Menu" echo -e "${green}\t0.${plain} Back to Main Menu"
read -rp "Choose an option: " choice read -rp "Choose an option: " choice
cd ${xui_folder}/bin
case "$choice" in case "$choice" in
0) 0)
show_menu show_menu
;; ;;
1) 1)
update_main_geofiles update_geofiles "main"
echo -e "${green}Loyalsoldier datasets have been updated successfully!${plain}" echo -e "${green}Loyalsoldier datasets have been updated successfully!${plain}"
restart restart
;; ;;
2) 2)
update_ir_geofiles update_geofiles "IR"
echo -e "${green}chocolate4u datasets have been updated successfully!${plain}" echo -e "${green}chocolate4u datasets have been updated successfully!${plain}"
restart restart
;; ;;
3) 3)
update_ru_geofiles update_geofiles "RU"
echo -e "${green}runetfreedom datasets have been updated successfully!${plain}" echo -e "${green}runetfreedom datasets have been updated successfully!${plain}"
restart restart
;; ;;
@ -1181,16 +1186,47 @@ ssl_cert_issue_for_ip() {
LOGI "Including IPv6 address: ${ipv6_addr}" LOGI "Including IPv6 address: ${ipv6_addr}"
fi fi
# Use port 80 for certificate issuance # Choose port for HTTP-01 listener (default 80, allow override)
local WebPort=80 local WebPort=""
read -rp "Port to use for ACME HTTP-01 listener (default 80): " WebPort
WebPort="${WebPort:-80}"
if ! [[ "${WebPort}" =~ ^[0-9]+$ ]] || ((WebPort < 1 || WebPort > 65535)); then
LOGE "Invalid port provided. Falling back to 80."
WebPort=80
fi
LOGI "Using port ${WebPort} to issue certificate for IP: ${server_ip}" LOGI "Using port ${WebPort} to issue certificate for IP: ${server_ip}"
LOGI "Make sure port ${WebPort} is open and not in use..." if [[ "${WebPort}" -ne 80 ]]; then
LOGI "Reminder: Let's Encrypt still reaches port 80; forward external port 80 to ${WebPort} for validation."
fi
while true; do
if is_port_in_use "${WebPort}"; then
LOGI "Port ${WebPort} is currently in use."
local alt_port=""
read -rp "Enter another port for acme.sh standalone listener (leave empty to abort): " alt_port
alt_port="${alt_port// /}"
if [[ -z "${alt_port}" ]]; then
LOGE "Port ${WebPort} is busy; cannot proceed with issuance."
return 1
fi
if ! [[ "${alt_port}" =~ ^[0-9]+$ ]] || ((alt_port < 1 || alt_port > 65535)); then
LOGE "Invalid port provided."
return 1
fi
WebPort="${alt_port}"
continue
else
LOGI "Port ${WebPort} is free and ready for standalone validation."
break
fi
done
# Reload command - restarts panel after renewal # Reload command - restarts panel after renewal
local reloadCmd="systemctl restart x-ui 2>/dev/null || rc-service x-ui restart 2>/dev/null" local reloadCmd="systemctl restart x-ui 2>/dev/null || rc-service x-ui restart 2>/dev/null"
# issue the certificate for IP with shortlived profile # issue the certificate for IP with shortlived profile
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt ~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force
~/.acme.sh/acme.sh --issue \ ~/.acme.sh/acme.sh --issue \
${domain_args} \ ${domain_args} \
--standalone \ --standalone \
@ -1355,7 +1391,7 @@ ssl_cert_issue() {
LOGI "Will use port: ${WebPort} to issue certificates. Please make sure this port is open." LOGI "Will use port: ${WebPort} to issue certificates. Please make sure this port is open."
# issue the certificate # issue the certificate
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt ~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force ~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
LOGE "Issuing certificate failed, please check logs." LOGE "Issuing certificate failed, please check logs."
@ -1482,7 +1518,7 @@ ssl_cert_issue_CF() {
LOGD "Your registered email address is: ${CF_AccountEmail}" LOGD "Your registered email address is: ${CF_AccountEmail}"
# Set the default CA to Let's Encrypt # Set the default CA to Let's Encrypt
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt ~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
LOGE "Default CA, Let'sEncrypt fail, script exiting..." LOGE "Default CA, Let'sEncrypt fail, script exiting..."
exit 1 exit 1
@ -2026,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
@ -119,6 +128,7 @@ type process struct {
onlineClients []string onlineClients []string
config *Config config *Config
configPath string // if set, use this path instead of GetConfigPath() and remove on Stop
logWriter *LogWriter logWriter *LogWriter
exitErr error exitErr error
startTime time.Time startTime time.Time
@ -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 {