Compare commits

..

51 Commits
v2.8.9 ... main

Author SHA1 Message Date
kazan417
38d87230d3 Update x-ui.sh (#3947)
looks like now cert management is option 19
2026-03-18 19:45:45 +01:00
MHSanaei
f0f98c7122 Add Go code analyzer workflow 2026-03-17 23:01:15 +01:00
Abdalrahman
554981d9d3 feat(tgbot): send connection links and qrs on client creation (closes #3320)\n\n- Refactored inline keyboards into getCommonClientButtons to respect DRY\n- Extended SubmitAddClient callback handlers to dispatch individual links and QR codes to the bot chat on success. (#3888) 2026-03-17 22:09:49 +01:00
Nikolay
a08f1c6c13 Update translate.ru_RU.toml (#3889)
Change to plural (geofiles, not geofile)
2026-03-17 21:24:09 +01:00
Alimpo
7f7ae0c547 fix: stop overwriting client_traffics.enable with JSON enable in GetClientTrafficByEmail (#3931)
When a client hit traffic/expiry limit, disableInvalidClients sets
client_traffics.enable=false and removes the user from Xray. GetClientTrafficByEmail
was overwriting that with settings.clients[].enable (admin config), so
ResetClientTraffic never saw the client as disabled and did not re-add
the user. Clients could not connect until manually disabled/re-enabled.
Now the DB runtime enable flag is preserved; reset correctly re-adds
the user to Xray.
2026-03-17 21:20:24 +01:00
HamidReza Sadeghzadeh
60abeaad66 fix: Ban new IPs with fail2ban instead of disconnected the client. (#3919)
* fix: Ban new IPs with fail2ban  instead of disconnected the client.

* fix: Remove unused strconv import

* fix: Revert log fail2ban format
2026-03-17 21:18:10 +01:00
dependabot[bot]
a6d0100381 Bump docker/metadata-action from 5 to 6 (#3942)
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 5 to 6.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-17 21:10:09 +01:00
dependabot[bot]
6767f76ccf Bump actions/upload-artifact from 4 to 7 (#3941)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-17 21:09:56 +01:00
dependabot[bot]
e4add73c9e Bump actions/checkout from 5 to 6 (#3940)
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-17 21:05:43 +01:00
dependabot[bot]
ff72090e1a Bump docker/setup-buildx-action from 3 to 4 (#3938)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-17 21:05:28 +01:00
dependabot[bot]
a3e1bd59df Bump docker/build-push-action from 6 to 7 (#3937)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6 to 7.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-17 21:05:07 +01:00
dependabot[bot]
5bbb48a8fd Bump docker/setup-qemu-action from 3 to 4 (#3936)
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-17 21:04:54 +01:00
dependabot[bot]
ee84d585f9 Bump docker/login-action from 3 to 4 (#3939)
Bumps [docker/login-action](https://github.com/docker/login-action) from 3 to 4.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-17 21:04:41 +01:00
Sanaei
7b03346cfc Set package ecosystem to GitHub Actions in dependabot.yml 2026-03-17 21:03:32 +01:00
MHSanaei
258b08fff3 Update fail2ban filter regex in x-ui.sh 2026-03-08 11:53:34 +01:00
Aleksei Sidorenko
a2097ad062 feat: mask password in telegram notification on 2FA failure (#3884) 2026-03-04 18:26:53 +01:00
MHSanaei
52fdf5d429 v2.8.11 2026-03-04 13:54:01 +01:00
MHSanaei
34d8885075 Adjust KCP MTU when selecting xDNS mask 2026-03-04 13:39:14 +01:00
MHSanaei
5740996436 update dependencies 2026-03-04 13:05:29 +01:00
Artur
874aae8080 Add cron to ubuntu packages (#3875) 2026-03-04 12:36:45 +01:00
子寒
842fae18d7 Add 'default' runlevel to x-ui service in Alpine (#3854)
it should be 'default' runlevel when add x-ui service to openrc, default is 'sysinit' runlevel. 'sysinit' runlevel is unnecessary,maybe.
if not, there is an error when call to function 'check_enabled()' as command 'grep default -c' can`t print 'default' runlevel.

check_enabled() {
    if [[ $release == "alpine" ]]; then
        if [[ $(rc-update show | grep -F 'x-ui' | grep default -c) == 1 ]]; then
            return 0
        else
            return 1
        fi
2026-03-04 12:32:01 +01:00
Happ-dev
ccd223aeea Fix DeepLink for Happ, remove encoding URL (#3863)
Co-authored-by: y.sivushkin <y.sivushkin@corp.101xp.com>
2026-03-04 12:29:46 +01:00
Aleksei Sidorenko
96b8fe472c Fix: escape HTML characters in tgbot start command (#3883) 2026-03-04 11:35:24 +01:00
Nabi KaramAliZadeh
59b695ba83 fix: remove excluded paths from gzip middleware in router initialization (#3860) 2026-03-01 15:18:16 +01:00
Alireza Ahmadi
159b85f979 Merge pull request #3828 from MHSanaei/restartXrayOption
[feat] restart xray-core from cli #3825
2026-02-25 21:09:42 +01:00
Alireza Ahmadi
3ec5b3589f fix windows build 2026-02-20 02:07:46 +01:00
Alireza Ahmadi
2b1d3e7347 [feat] restart xray-core from cli #3825 2026-02-20 00:03:16 +01:00
MHSanaei
37f0880f8f Bump Go to 1.26 2026-02-16 01:10:43 +01:00
MHSanaei
5b796672e9 Improve telego client robustness and retries
Add a createRobustFastHTTPClient helper to configure fasthttp.Client with better timeouts, connection limits, retries and optional SOCKS5 proxy dialing. Validate and sanitize proxy and API server URLs instead of returning early on invalid values, and build telego.Bot options dynamically. Reduce long-polling timeout to detect connection issues faster and adjust update retrieval comments. Implement exponential-backoff retry logic for SendMessage calls to handle transient connection/timeouts and improve delivery reliability; also reduce inter-message delay for better throughput.
2026-02-14 22:49:19 +01:00
MHSanaei
3fa0da38c9 Add timeouts and delays to backup sends
Add rate-limit friendly delays and context timeouts when sending backups via Telegram. Iterate admin IDs with index to sleep 1s between sends; add 30s context.WithTimeout for each SendDocument call and defer file.Close() for opened files; insert a 500ms pause between sending DB and config files. These changes improve resource cleanup and reduce chance of Telegram rate-limit/timeout failures.
2026-02-14 22:31:41 +01:00
MHSanaei
8eb1225734 translate bug fix #3789 2026-02-14 21:41:20 +01:00
MHSanaei
e5c0fe3edf bug fix #3785 2026-02-11 22:21:09 +01:00
MHSanaei
f4057989f5 Require HTTP 200 from curl before using IP
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)
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
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) 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)
- 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
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
65 changed files with 2558 additions and 1085 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

11
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,11 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "github-actions" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"

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

@@ -15,13 +15,13 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6
with: with:
submodules: true submodules: true
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v6
with: with:
images: | images: |
hsanaeii/3x-ui hsanaeii/3x-ui
@@ -32,28 +32,28 @@ jobs:
type=semver,pattern={{version}} type=semver,pattern={{version}}
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v4
with: with:
install: true install: true
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v3 uses: docker/login-action@v4
with: with:
username: ${{ secrets.DOCKER_HUB_USERNAME }} username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_TOKEN }} password: ${{ secrets.DOCKER_HUB_TOKEN }}
- name: Login to GHCR - name: Login to GHCR
uses: docker/login-action@v3 uses: docker/login-action@v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v7
with: with:
context: . context: .
push: true push: true

View File

@@ -2,11 +2,9 @@ name: Release 3X-UI
on: on:
workflow_dispatch: workflow_dispatch:
release:
types: [published]
push: push:
branches: branches:
- main - '**'
tags: tags:
- "v*.*.*" - "v*.*.*"
paths: paths:
@@ -20,9 +18,48 @@ on:
- 'x-ui.service.debian' - 'x-ui.service.debian'
- 'x-ui.service.arch' - 'x-ui.service.arch'
- 'x-ui.service.rhel' - 'x-ui.service.rhel'
pull_request:
jobs: jobs:
analyze:
name: Analyze Go code
permissions:
contents: read
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Check formatting
run: |
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "These files are not gofmt-formatted:"
echo "$unformatted"
exit 1
fi
- name: Run go vet
run: go vet ./...
- name: Run staticcheck
uses: dominikh/staticcheck-action@v1
with:
version: "latest"
install-go: false
- name: Run tests
run: go test -race -shuffle=on ./...
build: build:
needs: analyze
permissions: permissions:
contents: write contents: write
strategy: strategy:
@@ -38,7 +75,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@v6
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v6 uses: actions/setup-go@v6
@@ -89,7 +126,7 @@ jobs:
cd x-ui/bin cd x-ui/bin
# Download dependencies # Download dependencies
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.1.31/" Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.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
@@ -133,19 +170,17 @@ jobs:
run: tar -zcvf x-ui-linux-${{ matrix.platform }}.tar.gz x-ui run: tar -zcvf x-ui-linux-${{ matrix.platform }}.tar.gz x-ui
- name: Upload files to Artifacts - name: Upload files to Artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: x-ui-linux-${{ matrix.platform }} name: x-ui-linux-${{ matrix.platform }}
path: ./x-ui-linux-${{ matrix.platform }}.tar.gz path: ./x-ui-linux-${{ matrix.platform }}.tar.gz
- name: Upload files to GH release - name: Upload files to GH release
uses: svenstaro/upload-release-action@v2 uses: svenstaro/upload-release-action@v2
if: | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
(github.event_name == 'release' && github.event.action == 'published') ||
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/'))
with: with:
repo_token: ${{ secrets.GITHUB_TOKEN }} repo_token: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref }} tag: ${{ github.ref_name }}
file: x-ui-linux-${{ matrix.platform }}.tar.gz file: x-ui-linux-${{ matrix.platform }}.tar.gz
asset_name: x-ui-linux-${{ matrix.platform }}.tar.gz asset_name: x-ui-linux-${{ matrix.platform }}.tar.gz
overwrite: true overwrite: true
@@ -156,6 +191,7 @@ jobs:
# ================================= # =================================
build-windows: build-windows:
name: Build for Windows name: Build for Windows
needs: analyze
permissions: permissions:
contents: write contents: write
strategy: strategy:
@@ -165,7 +201,7 @@ jobs:
runs-on: windows-latest runs-on: windows-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@v6
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v6 uses: actions/setup-go@v6
@@ -173,21 +209,42 @@ jobs:
go-version-file: go.mod go-version-file: go.mod
check-latest: true check-latest: true
- name: Build 3X-UI for Windows - name: Install MSYS2
uses: msys2/setup-msys2@v2
with:
msystem: MINGW64
update: true
install: >-
mingw-w64-x86_64-gcc
mingw-w64-x86_64-sqlite3
mingw-w64-x86_64-pkg-config
- name: Build 3X-UI for Windows (CGO)
shell: msys2 {0}
run: |
export PATH="/c/hostedtoolcache/windows/go/$(ls /c/hostedtoolcache/windows/go | sort -V | tail -n1)/x64/bin:$PATH"
export CGO_ENABLED=1
export GOOS=windows
export GOARCH=amd64
export CC=x86_64-w64-mingw32-gcc
which go
go version
gcc --version
go build -ldflags "-w -s" -o xui-release.exe -v main.go
- name: Copy and download resources
shell: pwsh shell: pwsh
run: | run: |
$env:CGO_ENABLED="1"
$env:GOOS="windows"
$env:GOARCH="amd64"
go build -ldflags "-w -s" -o xui-release.exe -v main.go
mkdir x-ui mkdir x-ui
Copy-Item xui-release.exe x-ui\ Copy-Item xui-release.exe x-ui\x-ui.exe
mkdir x-ui\bin mkdir x-ui\bin
cd x-ui\bin cd x-ui\bin
# Download Xray for Windows # Download Xray for Windows
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.1.31/" $Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.2.6/"
Invoke-WebRequest -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip" Invoke-WebRequest -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath . Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
Remove-Item "Xray-windows-64.zip" Remove-Item "Xray-windows-64.zip"
@@ -209,19 +266,17 @@ jobs:
Compress-Archive -Path .\x-ui -DestinationPath "x-ui-windows-amd64.zip" Compress-Archive -Path .\x-ui -DestinationPath "x-ui-windows-amd64.zip"
- name: Upload files to Artifacts - name: Upload files to Artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: x-ui-windows-amd64 name: x-ui-windows-amd64
path: ./x-ui-windows-amd64.zip path: ./x-ui-windows-amd64.zip
- name: Upload files to GH release - name: Upload files to GH release
uses: svenstaro/upload-release-action@v2 uses: svenstaro/upload-release-action@v2
if: | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
(github.event_name == 'release' && github.event.action == 'published') ||
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/'))
with: with:
repo_token: ${{ secrets.GITHUB_TOKEN }} repo_token: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref }} tag: ${{ github.ref_name }}
file: x-ui-windows-amd64.zip file: x-ui-windows-amd64.zip
asset_name: x-ui-windows-amd64.zip asset_name: x-ui-windows-amd64.zip
overwrite: true overwrite: true

View File

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

View File

@@ -1,7 +1,7 @@
# ======================================================== # ========================================================
# Stage: Builder # Stage: Builder
# ======================================================== # ========================================================
FROM golang:1.25-alpine AS builder FROM golang:1.26-alpine AS builder
WORKDIR /app WORKDIR /app
ARG TARGETARCH ARG TARGETARCH
@@ -30,7 +30,8 @@ RUN apk add --no-cache --update \
tzdata \ tzdata \
fail2ban \ fail2ban \
bash \ bash \
curl curl \
openssl
COPY --from=builder /app/build/ /app/ COPY --from=builder /app/build/ /app/
COPY --from=builder /app/DockerEntrypoint.sh /app/ COPY --from=builder /app/DockerEntrypoint.sh /app/

View File

@@ -1 +1 @@
2.8.9 2.8.11

47
go.mod
View File

@@ -1,31 +1,31 @@
module github.com/mhsanaei/3x-ui/v2 module github.com/mhsanaei/3x-ui/v2
go 1.25.6 go 1.26.0
require ( require (
github.com/gin-contrib/gzip v1.2.5 github.com/gin-contrib/gzip v1.2.5
github.com/gin-contrib/sessions v1.0.4 github.com/gin-contrib/sessions v1.0.4
github.com/gin-gonic/gin v1.11.0 github.com/gin-gonic/gin v1.12.0
github.com/go-ldap/ldap/v3 v3.4.12 github.com/go-ldap/ldap/v3 v3.4.12
github.com/goccy/go-json v0.10.5 github.com/goccy/go-json v0.10.5
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/mymmrac/telego v1.5.0 github.com/mymmrac/telego v1.7.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.26.1 github.com/shirou/gopsutil/v4 v4.26.2
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/valyala/fasthttp v1.69.0 github.com/valyala/fasthttp v1.69.0
github.com/xlzd/gotp v0.1.0 github.com/xlzd/gotp v0.1.0
github.com/xtls/xray-core v1.260131.0 github.com/xtls/xray-core v1.260206.0
go.uber.org/atomic v1.11.0 go.uber.org/atomic v1.11.0
golang.org/x/crypto v0.47.0 golang.org/x/crypto v0.48.0
golang.org/x/sys v0.40.0 golang.org/x/sys v0.41.0
golang.org/x/text v0.33.0 golang.org/x/text v0.34.0
google.golang.org/grpc v1.78.0 google.golang.org/grpc v1.79.1
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
) )
@@ -39,8 +39,8 @@ require (
github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect github.com/cloudflare/circl v1.6.3 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/ebitengine/purego v0.9.1 // indirect github.com/ebitengine/purego v0.10.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect
@@ -57,44 +57,45 @@ require (
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/juju/ratelimit v1.0.2 // indirect github.com/juju/ratelimit v1.0.2 // indirect
github.com/klauspost/compress v1.18.3 // indirect github.com/klauspost/compress v1.18.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-20260216142805-b3301c5f2a88 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.33 // indirect github.com/mattn/go-sqlite3 v1.14.34 // indirect
github.com/miekg/dns v1.1.72 // indirect github.com/miekg/dns v1.1.72 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pires/go-proxyproto v0.9.2 // indirect github.com/pires/go-proxyproto v0.11.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect
github.com/refraction-networking/utls v1.8.2 // indirect github.com/refraction-networking/utls v1.8.2 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/sagernet/sing v0.7.18 // indirect github.com/sagernet/sing v0.8.1 // indirect
github.com/sagernet/sing-shadowsocks v0.2.9 // indirect github.com/sagernet/sing-shadowsocks v0.2.9 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect github.com/tklauser/numcpus v0.11.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fastjson v1.6.7 // indirect github.com/valyala/fastjson v1.6.10 // indirect
github.com/vishvananda/netlink v1.3.1 // indirect github.com/vishvananda/netlink v1.3.1 // indirect
github.com/vishvananda/netns v0.0.5 // indirect github.com/vishvananda/netns v0.0.5 // indirect
github.com/xtls/reality v0.0.0-20251116175510-cd53f7d50237 // indirect github.com/xtls/reality v0.0.0-20251116175510-cd53f7d50237 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/arch v0.23.0 // indirect golang.org/x/arch v0.24.0 // indirect
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/mod v0.32.0 // indirect golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.49.0 // indirect golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/time v0.14.0 // indirect golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.41.0 // indirect golang.org/x/tools v0.42.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
lukechampine.com/blake3 v1.4.1 // indirect lukechampine.com/blake3 v1.4.1 // indirect

112
go.sum
View File

@@ -14,6 +14,8 @@ github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uS
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
@@ -21,10 +23,10 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344 h1:Arcl6UOIS/kgO2nW3A65HN+7CMjSDP/gofXL4CZt1V4= github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344 h1:Arcl6UOIS/kgO2nW3A65HN+7CMjSDP/gofXL4CZt1V4=
github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344/go.mod h1:GIjDIg/heH5DOkXY3YJ/wNhfHsQHoXGjl8G8amsYQ1I= github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344/go.mod h1:GIjDIg/heH5DOkXY3YJ/wNhfHsQHoXGjl8G8amsYQ1I=
github.com/gin-contrib/gzip v1.2.5 h1:fIZs0S+l17pIu1P5XRJOo/YNqfIuPCrZZ3TWB7pjckI= github.com/gin-contrib/gzip v1.2.5 h1:fIZs0S+l17pIu1P5XRJOo/YNqfIuPCrZZ3TWB7pjckI=
@@ -33,8 +35,8 @@ github.com/gin-contrib/sessions v1.0.4 h1:ha6CNdpYiTOK/hTp05miJLbpTSNfOnFg5Jm2kb
github.com/gin-contrib/sessions v1.0.4/go.mod h1:ccmkrb2z6iU2osiAHZG3x3J4suJK+OU27oqzlWOqQgs= github.com/gin-contrib/sessions v1.0.4/go.mod h1:ccmkrb2z6iU2osiAHZG3x3J4suJK+OU27oqzlWOqQgs=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4= github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
@@ -105,8 +107,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/juju/ratelimit v1.0.2 h1:sRxmtRiajbvrcLQT7S+JbqU0ntsb9W2yhSdNN8tWfaI= github.com/juju/ratelimit v1.0.2 h1:sRxmtRiajbvrcLQT7S+JbqU0ntsb9W2yhSdNN8tWfaI=
github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk= github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk=
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -115,12 +117,12 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k= github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 h1:PTw+yKnXcOFCR6+8hHTyWBeQ/P4Nb7dd4/0ohEcWQuM=
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -128,8 +130,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mymmrac/telego v1.5.0 h1:VjBDZcSpEQim1Y3JX2WCsF/PJqOA2DKfZknXUvtKCnw= github.com/mymmrac/telego v1.7.0 h1:yRO/l00tFGG4nY66ufUKb4ARqv7qx9+LsjQv/b0NEyo=
github.com/mymmrac/telego v1.5.0/go.mod h1:MDYHIeT68tURdcwH4SNCQQ+0xBC3u6wOcH2hBpa4Ip0= github.com/mymmrac/telego v1.7.0/go.mod h1:pdLV346EgVuq7Xrh3kMggeBiazeHhsdEoK0RTEOPXRM=
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ= github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA= github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88= github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88=
@@ -138,8 +140,8 @@ github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3v
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pires/go-proxyproto v0.9.2 h1:H1UdHn695zUVVmB0lQ354lOWHOy6TZSpzBl3tgN0s1U= github.com/pires/go-proxyproto v0.11.0 h1:gUQpS85X/VJMdUsYyEgyn59uLJvGqPhJV5YvG68wXH4=
github.com/pires/go-proxyproto v0.9.2/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= github.com/pires/go-proxyproto v0.11.0/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
@@ -154,12 +156,12 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sagernet/sing v0.7.18 h1:iZHkaru1/MoHugx3G+9S3WG4owMewKO/KvieE2Pzk4E= github.com/sagernet/sing v0.8.1 h1:Li+zg4xdiMsvdX4j50TPqmSG8LF/TB9US2qlAN40izU=
github.com/sagernet/sing v0.7.18/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak= github.com/sagernet/sing v0.8.1/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak=
github.com/sagernet/sing-shadowsocks v0.2.9 h1:Paep5zCszRKsEn8587O0MnhFWKJwDW1Y4zOYYlIxMkM= github.com/sagernet/sing-shadowsocks v0.2.9 h1:Paep5zCszRKsEn8587O0MnhFWKJwDW1Y4zOYYlIxMkM=
github.com/sagernet/sing-shadowsocks v0.2.9/go.mod h1:TE/Z6401Pi8tgr0nBZcM/xawAI6u3F6TTbz4nH/qw+8= github.com/sagernet/sing-shadowsocks v0.2.9/go.mod h1:TE/Z6401Pi8tgr0nBZcM/xawAI6u3F6TTbz4nH/qw+8=
github.com/shirou/gopsutil/v4 v4.26.1 h1:TOkEyriIXk2HX9d4isZJtbjXbEjf5qyKPAzbzY0JWSo= github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI=
github.com/shirou/gopsutil/v4 v4.26.1/go.mod h1:medLI9/UNAb0dOI9Q3/7yWSqKkj00u+1tgY8nvv41pc= github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -185,8 +187,8 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw
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=
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw= github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM= github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0= github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4= github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
@@ -195,24 +197,26 @@ github.com/xlzd/gotp v0.1.0 h1:37blvlKCh38s+fkem+fFh7sMnceltoIEBYTVXyoa5Po=
github.com/xlzd/gotp v0.1.0/go.mod h1:ndLJ3JKzi3xLmUProq4LLxCuECL93dG9WASNLpHz8qg= github.com/xlzd/gotp v0.1.0/go.mod h1:ndLJ3JKzi3xLmUProq4LLxCuECL93dG9WASNLpHz8qg=
github.com/xtls/reality v0.0.0-20251116175510-cd53f7d50237 h1:UXjrmniKlY+ZbIqpN91lejB3pszQQQRVu1vqH/p/aGM= github.com/xtls/reality v0.0.0-20251116175510-cd53f7d50237 h1:UXjrmniKlY+ZbIqpN91lejB3pszQQQRVu1vqH/p/aGM=
github.com/xtls/reality v0.0.0-20251116175510-cd53f7d50237/go.mod h1:vbHCV/3VWUvy1oKvTxxWJRPEWSeR1sYgQHIh6u/JiZQ= github.com/xtls/reality v0.0.0-20251116175510-cd53f7d50237/go.mod h1:vbHCV/3VWUvy1oKvTxxWJRPEWSeR1sYgQHIh6u/JiZQ=
github.com/xtls/xray-core v1.260131.0 h1:gPBykLhUvRZ8sfubNerkwWqV3c15UtmSYQG2cgKqrV4= github.com/xtls/xray-core v1.260206.0 h1:gY8IV6u76CW93txL9QmacgZ0Udxr2Q3e9qUxXAhdHqI=
github.com/xtls/xray-core v1.260131.0/go.mod h1:cxzYFZrxu1B1NtPjHsqv4UzgDvRA71mV4rXYH4KtO7Q= github.com/xtls/xray-core v1.260206.0/go.mod h1:GyFIgVGRJkt3eyV/NMcdxOKXcJPqGGpyupHzy16uJhU=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
@@ -221,16 +225,16 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg= golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -239,24 +243,24 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A= golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -76,7 +76,7 @@ is_port_in_use() {
install_base() { install_base() {
case "${release}" in case "${release}" in
ubuntu | debian | armbian) ubuntu | debian | armbian)
apt-get update && apt-get install -y -q curl tar tzdata socat ca-certificates apt-get update && apt-get install -y -q cron curl tar tzdata socat ca-certificates
;; ;;
fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol) fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol)
dnf -y update && dnf install -y -q curl tar tzdata socat ca-certificates dnf -y update && dnf install -y -q curl tar tzdata socat ca-certificates
@@ -654,8 +654,11 @@ config_after_install() {
) )
local server_ip="" local server_ip=""
for ip_address in "${URL_lists[@]}"; do for ip_address in "${URL_lists[@]}"; do
server_ip=$(curl -s --max-time 3 "${ip_address}" 2>/dev/null | tr -d '[:space:]') local response=$(curl -s -w "\n%{http_code}" --max-time 3 "${ip_address}" 2>/dev/null)
if [[ -n "${server_ip}" ]]; then local http_code=$(echo "$response" | tail -n1)
local ip_result=$(echo "$response" | head -n-1 | tr -d '[:space:]')
if [[ "${http_code}" == "200" && -n "${ip_result}" ]]; then
server_ip="${ip_result}"
break break
fi fi
done done

View File

@@ -16,6 +16,7 @@ import (
"github.com/mhsanaei/3x-ui/v2/logger" "github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/sub" "github.com/mhsanaei/3x-ui/v2/sub"
"github.com/mhsanaei/3x-ui/v2/util/crypto" "github.com/mhsanaei/3x-ui/v2/util/crypto"
"github.com/mhsanaei/3x-ui/v2/util/sys"
"github.com/mhsanaei/3x-ui/v2/web" "github.com/mhsanaei/3x-ui/v2/web"
"github.com/mhsanaei/3x-ui/v2/web/global" "github.com/mhsanaei/3x-ui/v2/web/global"
"github.com/mhsanaei/3x-ui/v2/web/service" "github.com/mhsanaei/3x-ui/v2/web/service"
@@ -70,7 +71,7 @@ func runWebServer() {
sigCh := make(chan os.Signal, 1) sigCh := make(chan os.Signal, 1)
// Trap shutdown signals // Trap shutdown signals
signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGTERM) signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGTERM, sys.SIGUSR1)
for { for {
sig := <-sigCh sig := <-sigCh
@@ -108,6 +109,12 @@ func runWebServer() {
return return
} }
log.Println("Sub server restarted successfully.") log.Println("Sub server restarted successfully.")
case sys.SIGUSR1:
logger.Info("Received USR1 signal, restarting xray-core...")
err := server.RestartXray()
if err != nil {
logger.Error("Failed to restart xray-core:", err)
}
default: default:
// --- FIX FOR TELEGRAM BOT CONFLICT (409) on full shutdown --- // --- FIX FOR TELEGRAM BOT CONFLICT (409) on full shutdown ---

View File

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

View File

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

View File

@@ -270,9 +270,6 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok { if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok {
obj["fp"], _ = fpValue.(string) obj["fp"], _ = fpValue.(string)
} }
if insecure, ok := searchKey(tlsSettings, "allowInsecure"); ok {
obj["allowInsecure"], _ = insecure.(bool)
}
} }
} }
@@ -296,7 +293,7 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
newSecurity, _ := ep["forceTls"].(string) newSecurity, _ := ep["forceTls"].(string)
newObj := map[string]any{} newObj := map[string]any{}
for key, value := range obj { for key, value := range obj {
if !(newSecurity == "none" && (key == "alpn" || key == "sni" || key == "fp" || key == "allowInsecure")) { if !(newSecurity == "none" && (key == "alpn" || key == "sni" || key == "fp")) {
newObj[key] = value newObj[key] = value
} }
} }
@@ -431,11 +428,6 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok { if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok {
params["fp"], _ = fpValue.(string) params["fp"], _ = fpValue.(string)
} }
if insecure, ok := searchKey(tlsSettings, "allowInsecure"); ok {
if insecure.(bool) {
params["allowInsecure"] = "1"
}
}
} }
if streamNetwork == "tcp" && len(clients[clientIndex].Flow) > 0 { if streamNetwork == "tcp" && len(clients[clientIndex].Flow) > 0 {
@@ -501,7 +493,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
q := url.Query() q := url.Query()
for k, v := range params { for k, v := range params {
if !(newSecurity == "none" && (k == "alpn" || k == "sni" || k == "fp" || k == "allowInsecure")) { if !(newSecurity == "none" && (k == "alpn" || k == "sni" || k == "fp")) {
q.Add(k, v) q.Add(k, v)
} }
} }
@@ -632,11 +624,6 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok { if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok {
params["fp"], _ = fpValue.(string) params["fp"], _ = fpValue.(string)
} }
if insecure, ok := searchKey(tlsSettings, "allowInsecure"); ok {
if insecure.(bool) {
params["allowInsecure"] = "1"
}
}
} }
} }
@@ -698,7 +685,7 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
q := url.Query() q := url.Query()
for k, v := range params { for k, v := range params {
if !(newSecurity == "none" && (k == "alpn" || k == "sni" || k == "fp" || k == "allowInsecure")) { if !(newSecurity == "none" && (k == "alpn" || k == "sni" || k == "fp")) {
q.Add(k, v) q.Add(k, v)
} }
} }
@@ -837,11 +824,6 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok { if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok {
params["fp"], _ = fpValue.(string) params["fp"], _ = fpValue.(string)
} }
if insecure, ok := searchKey(tlsSettings, "allowInsecure"); ok {
if insecure.(bool) {
params["allowInsecure"] = "1"
}
}
} }
} }
@@ -870,7 +852,7 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
q := url.Query() q := url.Query()
for k, v := range params { for k, v := range params {
if !(newSecurity == "none" && (k == "alpn" || k == "sni" || k == "fp" || k == "allowInsecure")) { if !(newSecurity == "none" && (k == "alpn" || k == "sni" || k == "fp")) {
q.Add(k, v) q.Add(k, v)
} }
} }

View File

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

View File

@@ -7,11 +7,14 @@ import (
"encoding/binary" "encoding/binary"
"fmt" "fmt"
"sync" "sync"
"syscall"
"github.com/shirou/gopsutil/v4/net" "github.com/shirou/gopsutil/v4/net"
"golang.org/x/sys/unix" "golang.org/x/sys/unix"
) )
var SIGUSR1 = syscall.SIGUSR1
func GetTCPCount() (int, error) { func GetTCPCount() (int, error) {
stats, err := net.Connections("tcp") stats, err := net.Connections("tcp")
if err != nil { if err != nil {

View File

@@ -12,8 +12,11 @@ import (
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"syscall"
) )
var SIGUSR1 = syscall.SIGUSR1
func getLinesNum(filename string) (int, error) { func getLinesNum(filename string) (int, error) {
file, err := os.Open(filename) file, err := os.Open(filename)
if err != nil { if err != nil {

View File

@@ -12,6 +12,8 @@ import (
"github.com/shirou/gopsutil/v4/net" "github.com/shirou/gopsutil/v4/net"
) )
var SIGUSR1 = syscall.Signal(0)
// GetConnectionCount returns the number of active connections for the specified protocol ("tcp" or "udp"). // GetConnectionCount returns the number of active connections for the specified protocol ("tcp" or "udp").
func GetConnectionCount(proto string) (int, error) { func GetConnectionCount(proto string) (int, error) {
if proto != "tcp" && proto != "udp" { if proto != "tcp" && proto != "udp" {

View File

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

View File

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

View File

@@ -144,7 +144,7 @@
return this.app.subUrl; return this.app.subUrl;
}, },
happUrl() { happUrl() {
return `happ://add/${encodeURIComponent(this.app.subUrl)}`; return `happ://add/${this.app.subUrl}`;
} }
}, },
methods: { methods: {

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,7 @@
package controller package controller
import ( import (
"fmt"
"net/http" "net/http"
"text/template" "text/template"
"time" "time"
@@ -71,14 +72,22 @@ func (a *IndexController) login(c *gin.Context) {
return return
} }
user := a.userService.CheckUser(form.Username, form.Password, form.TwoFactorCode) user, checkErr := a.userService.CheckUser(form.Username, form.Password, form.TwoFactorCode)
timeStr := time.Now().Format("2006-01-02 15:04:05") timeStr := time.Now().Format("2006-01-02 15:04:05")
safeUser := template.HTMLEscapeString(form.Username) safeUser := template.HTMLEscapeString(form.Username)
safePass := template.HTMLEscapeString(form.Password) safePass := template.HTMLEscapeString(form.Password)
if user == nil { if user == nil {
logger.Warningf("wrong username: \"%s\", password: \"%s\", IP: \"%s\"", safeUser, safePass, getRemoteIp(c)) logger.Warningf("wrong username: \"%s\", password: \"%s\", IP: \"%s\"", safeUser, safePass, getRemoteIp(c))
a.tgbot.UserLoginNotify(safeUser, safePass, getRemoteIp(c), timeStr, 0)
notifyPass := safePass
if checkErr != nil && checkErr.Error() == "invalid 2fa code" {
translatedError := a.tgbot.I18nBot("tgbot.messages.2faFailed")
notifyPass = fmt.Sprintf("*** (%s)", translatedError)
}
a.tgbot.UserLoginNotify(safeUser, notifyPass, getRemoteIp(c), timeStr, 0)
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.wrongUsernameOrPassword")) pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.wrongUsernameOrPassword"))
return return
} }

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

@@ -612,7 +612,7 @@
</a-divider> </a-divider>
<a-form-item label='Type'> <a-form-item label='Type'>
<a-select v-model="mask.type" <a-select v-model="mask.type"
@change="(type) => mask.settings = mask._getDefaultSettings(type, {})" @change="(type) => { mask.settings = mask._getDefaultSettings(type, {}); if(outbound.stream.network === 'kcp') { outbound.stream.kcp.mtu = type === 'xdns' ? 900 : 1350; } }"
:dropdown-class-name="themeSwitcher.currentTheme"> :dropdown-class-name="themeSwitcher.currentTheme">
<!-- Salamander for Hysteria2 only --> <!-- Salamander for Hysteria2 only -->
<a-select-option v-if="outbound.protocol === Protocols.Hysteria" <a-select-option v-if="outbound.protocol === Protocols.Hysteria"
@@ -643,9 +643,9 @@
<a-select-option v-if="outbound.stream.network === 'kcp'" <a-select-option v-if="outbound.stream.network === 'kcp'"
value="mkcp-original"> value="mkcp-original">
mKCP Original</a-select-option> mKCP Original</a-select-option>
<!-- xDNS for TCP/WS/HTTPUpgrade/XHTTP --> <!-- xDNS for TCP/WS/HTTPUpgrade/XHTTP/KCP -->
<a-select-option <a-select-option
v-if="['tcp', 'ws', 'httpupgrade', 'xhttp'].includes(outbound.stream.network)" v-if="['tcp', 'ws', 'httpupgrade', 'xhttp', 'kcp'].includes(outbound.stream.network)"
value="xdns"> value="xdns">
xDNS (Experimental)</a-select-option> xDNS (Experimental)</a-select-option>
</a-select> </a-select>
@@ -700,14 +700,12 @@
<a-form-item label="ECH Config List"> <a-form-item label="ECH Config List">
<a-input v-model.trim="outbound.stream.tls.echConfigList"></a-input> <a-input v-model.trim="outbound.stream.tls.echConfigList"></a-input>
</a-form-item> </a-form-item>
<a-form-item label="Allow Insecure">
<a-switch v-model="outbound.stream.tls.allowInsecure"></a-switch>
</a-form-item>
<a-form-item label="verify Peer Cert By Name"> <a-form-item label="verify Peer Cert By Name">
<a-input <a-input
v-model.trim="outbound.stream.tls.verifyPeerCertByName"></a-input> v-model.trim="outbound.stream.tls.verifyPeerCertByName"
placeholder="cloudflare-dns.com"></a-input>
</a-form-item> </a-form-item>
<a-form-item label="pinned Peer Cert Sha256"> <a-form-item label=" pinned Peer Cert Sha256">
<a-input v-model.trim="outbound.stream.tls.pinnedPeerCertSha256" <a-input v-model.trim="outbound.stream.tls.pinnedPeerCertSha256"
placeholder="Enter SHA256 fingerprints (base64)"> placeholder="Enter SHA256 fingerprints (base64)">
</a-input> </a-input>
@@ -772,7 +770,8 @@
<a-switch v-model="outbound.stream.sockopt.tcpFastOpen"></a-switch> <a-switch v-model="outbound.stream.sockopt.tcpFastOpen"></a-switch>
</a-form-item> </a-form-item>
<a-form-item label="Multipath TCP"> <a-form-item label="Multipath TCP">
<a-switch v-model.trim="outbound.stream.sockopt.tcpMptcp"></a-switch> <a-switch
v-model.trim="outbound.stream.sockopt.tcpMptcp"></a-switch>
</a-form-item> </a-form-item>
<a-form-item label="Penetrate"> <a-form-item label="Penetrate">
<a-switch v-model="outbound.stream.sockopt.penetrate"></a-switch> <a-switch v-model="outbound.stream.sockopt.penetrate"></a-switch>
@@ -799,7 +798,8 @@
</a-form-item> </a-form-item>
<template v-if="outbound.mux.enabled"> <template v-if="outbound.mux.enabled">
<a-form-item label="Concurrency"> <a-form-item label="Concurrency">
<a-input-number v-model.number="outbound.mux.concurrency" :min="-1" <a-input-number v-model.number="outbound.mux.concurrency"
:min="-1"
:max="1024"></a-input-number> :max="1024"></a-input-number>
</a-form-item> </a-form-item>
<a-form-item label="xudp Concurrency"> <a-form-item label="xudp Concurrency">

View File

@@ -18,7 +18,7 @@
</a-divider> </a-divider>
<a-form-item label='Type'> <a-form-item label='Type'>
<a-select v-model="mask.type" <a-select v-model="mask.type"
@change="(type) => mask.settings = mask._getDefaultSettings(type, {})" @change="(type) => { mask.settings = mask._getDefaultSettings(type, {}); if(inbound.stream.network === 'kcp') { inbound.stream.kcp.mtu = type === 'xdns' ? 900 : 1350; } }"
:dropdown-class-name="themeSwitcher.currentTheme"> :dropdown-class-name="themeSwitcher.currentTheme">
<!-- mKCP-specific masks --> <!-- mKCP-specific masks -->
<a-select-option v-if="inbound.stream.network === 'kcp'" <a-select-option v-if="inbound.stream.network === 'kcp'"
@@ -45,9 +45,12 @@
<a-select-option v-if="inbound.stream.network === 'kcp'" <a-select-option v-if="inbound.stream.network === 'kcp'"
value="mkcp-original"> value="mkcp-original">
mKCP Original</a-select-option> mKCP Original</a-select-option>
<!-- xDNS for TCP/WS/HTTPUpgrade/XHTTP --> <a-select-option v-if="inbound.stream.network === 'kcp'"
value="xicmp">
xICMP (Experimental)</a-select-option>
<!-- xDNS for TCP/WS/HTTPUpgrade/XHTTP/KCP -->
<a-select-option <a-select-option
v-if="['tcp', 'ws', 'httpupgrade', 'xhttp'].includes(inbound.stream.network)" v-if="['tcp', 'ws', 'httpupgrade', 'xhttp', 'kcp'].includes(inbound.stream.network)"
value="xdns"> value="xdns">
xDNS (Experimental)</a-select-option> xDNS (Experimental)</a-select-option>
</a-select> </a-select>
@@ -64,6 +67,17 @@
<a-input v-model.trim="mask.settings.domain" <a-input v-model.trim="mask.settings.domain"
placeholder="e.g., www.example.com"></a-input> placeholder="e.g., www.example.com"></a-input>
</a-form-item> </a-form-item>
<!-- Settings for xICMP -->
<a-form-item label='IP'
v-if="mask.type === 'xicmp'">
<a-input v-model.trim="mask.settings.ip"
placeholder="e.g., 1.1.1.1"></a-input>
</a-form-item>
<a-form-item label='ID'
v-if="mask.type === 'xicmp'">
<a-input-number v-model.number="mask.settings.id"
:min="0" :max="65535"></a-input-number>
</a-form-item>
</a-form> </a-form>
</template> </template>
</a-form> </a-form>

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -206,7 +206,7 @@
<a-menu-item key="android-npvtunnel" @click="copy(app.subUrl)">NPV <a-menu-item key="android-npvtunnel" @click="copy(app.subUrl)">NPV
Tunnel</a-menu-item> Tunnel</a-menu-item>
<a-menu-item key="android-happ" <a-menu-item key="android-happ"
@click="open('happ://add/' + encodeURIComponent(app.subUrl))">Happ</a-menu-item> @click="open('happ://add/' + app.subUrl)">Happ</a-menu-item>
</a-menu> </a-menu>
</a-dropdown> </a-dropdown>
</a-col> </a-col>

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

@@ -18,6 +18,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 +125,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 +155,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 +238,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 +264,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 +280,58 @@ 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 (oldest first)
// This ensures we always protect the original/current connections and ban new excess ones.
allIps := make([]IPWithTimestamp, 0, len(ipMap))
for ip, timestamp := range ipMap {
allIps = append(allIps, IPWithTimestamp{IP: ip, Timestamp: timestamp})
}
sort.Slice(allIps, func(i, j int) bool {
return allIps[i].Timestamp < allIps[j].Timestamp // Ascending order (oldest 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,27 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
log.SetOutput(logIpFile) log.SetOutput(logIpFile)
log.SetFlags(log.LstdFlags) log.SetFlags(log.LstdFlags)
for _, client := range clients { // Check if we exceed the limit
if client.Email == clientEmail { if len(allIps) > limitIp {
limitIp := client.LimitIP shouldCleanLog = true
if limitIp > 0 && inbound.Enable { // Keep the oldest IPs (currently active connections) and ban the new excess ones.
shouldCleanLog = true keptIps := allIps[:limitIp]
bannedIps := allIps[limitIp:]
if limitIp < len(ips) { // Log banned IPs in the format fail2ban filters expect: [LIMIT_IP] Email = X || Disconnecting OLD IP = Y || Timestamp = Z
j.disAllowedIps = append(j.disAllowedIps, ips[limitIp:]...) for _, ipTime := range bannedIps {
for i := limitIp; i < len(ips); i++ { j.disAllowedIps = append(j.disAllowedIps, ipTime.IP)
log.Printf("[LIMIT_IP] Email = %s || SRC = %s", clientEmail, ips[i]) log.Printf("[LIMIT_IP] Email = %s || Disconnecting OLD IP = %s || Timestamp = %d", clientEmail, ipTime.IP, ipTime.Timestamp)
}
}
}
} }
}
sort.Strings(j.disAllowedIps) // Update database with only the currently active (kept) IPs
jsonIps, _ := json.Marshal(keptIps)
if len(j.disAllowedIps) > 0 { inboundClientIps.Ips = string(jsonIps)
logger.Debug("disAllowedIps:", j.disAllowedIps) } else {
// Under limit, save all IPs
jsonIps, _ := json.Marshal(allIps)
inboundClientIps.Ips = string(jsonIps)
} }
db := database.GetDB() db := database.GetDB()
@@ -305,6 +371,10 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
return false return false
} }
if len(j.disAllowedIps) > 0 {
logger.Infof("[LIMIT_IP] Client %s: Kept %d current IPs, queued %d new IPs for fail2ban", clientEmail, limitIp, len(j.disAllowedIps))
}
return shouldCleanLog return shouldCleanLog
} }

View File

@@ -271,10 +271,7 @@ func (j *LdapSyncJob) deleteClientsNotInLDAP(inboundTag string, ldapEmails map[s
// Delete in batches // Delete in batches
for i := 0; i < len(toDelete); i += batchSize { for i := 0; i < len(toDelete); i += batchSize {
end := i + batchSize end := min(i+batchSize, len(toDelete))
if end > len(toDelete) {
end = len(toDelete)
}
batch := toDelete[i:end] batch := toDelete[i:end]
for _, c := range batch { for _, c := range batch {

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

@@ -37,7 +37,7 @@ type SettingService interface {
// InitLocalizer initializes the internationalization system with embedded translation files. // InitLocalizer initializes the internationalization system with embedded translation files.
func InitLocalizer(i18nFS embed.FS, settingService SettingService) error { func InitLocalizer(i18nFS embed.FS, settingService SettingService) error {
// set default bundle to english // set default bundle to English
i18nBundle = i18n.NewBundle(language.MustParse("en-US")) i18nBundle = i18n.NewBundle(language.MustParse("en-US"))
i18nBundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) i18nBundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"net"
"reflect" "reflect"
"strconv" "strconv"
"strings" "strings"
@@ -78,6 +79,8 @@ var defaultValueMap = map[string]string{
"warp": "", "warp": "",
"externalTrafficInformEnable": "false", "externalTrafficInformEnable": "false",
"externalTrafficInformURI": "", "externalTrafficInformURI": "",
"xrayOutboundTestUrl": "https://www.google.com/generate_204",
// LDAP defaults // LDAP defaults
"ldapEnable": "false", "ldapEnable": "false",
"ldapHost": "", "ldapHost": "",
@@ -105,7 +108,7 @@ var defaultValueMap = map[string]string{
// It handles configuration storage, retrieval, and validation for all system settings. // It handles configuration storage, retrieval, and validation for all system settings.
type SettingService struct{} type SettingService struct{}
func (s *SettingService) GetDefaultJsonConfig() (any, error) { func (s *SettingService) GetDefaultJSONConfig() (any, error) {
var jsonData any var jsonData any
err := json.Unmarshal([]byte(xrayTemplateConfig), &jsonData) err := json.Unmarshal([]byte(xrayTemplateConfig), &jsonData)
if err != nil { if err != nil {
@@ -122,7 +125,7 @@ func (s *SettingService) GetAllSetting() (*entity.AllSetting, error) {
return nil, err return nil, err
} }
allSetting := &entity.AllSetting{} allSetting := &entity.AllSetting{}
t := reflect.TypeOf(allSetting).Elem() t := reflect.TypeFor[entity.AllSetting]()
v := reflect.ValueOf(allSetting).Elem() v := reflect.ValueOf(allSetting).Elem()
fields := reflect_util.GetFields(t) fields := reflect_util.GetFields(t)
@@ -271,6 +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")
} }
@@ -596,7 +607,7 @@ func (s *SettingService) GetIpLimitEnable() (bool, error) {
return (accessLogPath != "none" && accessLogPath != ""), nil return (accessLogPath != "none" && accessLogPath != ""), nil
} }
// LDAP exported getters // GetLdapEnable returns whether LDAP is enabled.
func (s *SettingService) GetLdapEnable() (bool, error) { func (s *SettingService) GetLdapEnable() (bool, error) {
return s.getBool("ldapEnable") return s.getBool("ldapEnable")
} }
@@ -683,7 +694,7 @@ func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
} }
v := reflect.ValueOf(allSetting).Elem() v := reflect.ValueOf(allSetting).Elem()
t := reflect.TypeOf(allSetting).Elem() t := reflect.TypeFor[entity.AllSetting]()
fields := reflect_util.GetFields(t) fields := reflect_util.GetFields(t)
errs := make([]error, 0) errs := make([]error, 0)
for _, field := range fields { for _, field := range fields {
@@ -707,6 +718,28 @@ func (s *SettingService) GetDefaultXrayConfig() (any, error) {
return jsonData, nil return jsonData, nil
} }
func extractHostname(host string) string {
h, _, err := net.SplitHostPort(host)
// Err is not nil means host does not contain port
if err != nil {
h = host
}
ip := net.ParseIP(h)
// If it's not an IP, return as is
if ip == nil {
return h
}
// If it's an IPv4, return as is
if ip.To4() != nil {
return h
}
// IPv6 needs bracketing
return "[" + h + "]"
}
func (s *SettingService) GetDefaultSettings(host string) (any, error) { func (s *SettingService) GetDefaultSettings(host string) (any, error) {
type settingFunc func() (any, error) type settingFunc func() (any, error)
settings := map[string]settingFunc{ settings := map[string]settingFunc{
@@ -757,7 +790,7 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
subTLS = true subTLS = true
} }
if subDomain == "" { if subDomain == "" {
subDomain = strings.Split(host, ":")[0] subDomain = extractHostname(host)
} }
if subTLS { if subTLS {
subURI = "https://" subURI = "https://"

View File

@@ -5,8 +5,10 @@ import (
"crypto/rand" "crypto/rand"
"embed" "embed"
"encoding/base64" "encoding/base64"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"html"
"io" "io"
"math/big" "math/big"
"net" "net"
@@ -14,6 +16,7 @@ import (
"net/url" "net/url"
"os" "os"
"regexp" "regexp"
"slices"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -271,41 +274,78 @@ func (t *Tgbot) Start(i18nFS embed.FS) error {
return nil return nil
} }
// createRobustFastHTTPClient creates a fasthttp.Client with proper connection handling
func (t *Tgbot) createRobustFastHTTPClient(proxyUrl string) *fasthttp.Client {
client := &fasthttp.Client{
// Connection timeouts
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
MaxIdleConnDuration: 60 * time.Second,
MaxConnDuration: 0, // unlimited, but controlled by MaxIdleConnDuration
MaxIdemponentCallAttempts: 3,
ReadBufferSize: 4096,
WriteBufferSize: 4096,
MaxConnsPerHost: 100,
MaxConnWaitTimeout: 10 * time.Second,
DisableHeaderNamesNormalizing: false,
DisablePathNormalizing: false,
// Retry on connection errors
RetryIf: func(request *fasthttp.Request) bool {
// Retry on connection errors for GET requests
return string(request.Header.Method()) == "GET" || string(request.Header.Method()) == "POST"
},
}
// Set proxy if provided
if proxyUrl != "" {
client.Dial = fasthttpproxy.FasthttpSocksDialer(proxyUrl)
}
return client
}
// NewBot creates a new Telegram bot instance with optional proxy and API server settings. // NewBot creates a new Telegram bot instance with optional proxy and API server settings.
func (t *Tgbot) NewBot(token string, proxyUrl string, apiServerUrl string) (*telego.Bot, error) { func (t *Tgbot) NewBot(token string, proxyUrl string, apiServerUrl string) (*telego.Bot, error) {
if proxyUrl == "" && apiServerUrl == "" { // Validate proxy URL if provided
return telego.NewBot(token)
}
if proxyUrl != "" { if proxyUrl != "" {
if !strings.HasPrefix(proxyUrl, "socks5://") { if !strings.HasPrefix(proxyUrl, "socks5://") {
logger.Warning("Invalid socks5 URL, using default") logger.Warning("Invalid socks5 URL, ignoring proxy")
return telego.NewBot(token) proxyUrl = "" // Clear invalid proxy
} else {
_, err := url.Parse(proxyUrl)
if err != nil {
logger.Warningf("Can't parse proxy URL, ignoring proxy: %v", err)
proxyUrl = ""
}
} }
}
_, err := url.Parse(proxyUrl) // Validate API server URL if provided
if err != nil { if apiServerUrl != "" {
logger.Warningf("Can't parse proxy URL, using default instance for tgbot: %v", err) if !strings.HasPrefix(apiServerUrl, "http") {
return telego.NewBot(token) logger.Warning("Invalid http(s) URL for API server, using default")
apiServerUrl = ""
} else {
_, err := url.Parse(apiServerUrl)
if err != nil {
logger.Warningf("Can't parse API server URL, using default: %v", err)
apiServerUrl = ""
}
} }
return telego.NewBot(token, telego.WithFastHTTPClient(&fasthttp.Client{
Dial: fasthttpproxy.FasthttpSocksDialer(proxyUrl),
}))
} }
if !strings.HasPrefix(apiServerUrl, "http") { // Create robust fasthttp client
logger.Warning("Invalid http(s) URL, using default") client := t.createRobustFastHTTPClient(proxyUrl)
return telego.NewBot(token)
// Build bot options
var options []telego.BotOption
options = append(options, telego.WithFastHTTPClient(client))
if apiServerUrl != "" {
options = append(options, telego.WithAPIServer(apiServerUrl))
} }
_, err := url.Parse(apiServerUrl) return telego.NewBot(token, options...)
if err != nil {
logger.Warningf("Can't parse API server URL, using default instance for tgbot: %v", err)
return telego.NewBot(token)
}
return telego.NewBot(token, telego.WithAPIServer(apiServerUrl))
} }
// IsRunning checks if the Telegram bot is currently running. // IsRunning checks if the Telegram bot is currently running.
@@ -389,7 +429,7 @@ func (t *Tgbot) decodeQuery(query string) (string, error) {
// OnReceive starts the message receiving loop for the Telegram bot. // OnReceive starts the message receiving loop for the Telegram bot.
func (t *Tgbot) OnReceive() { func (t *Tgbot) OnReceive() {
params := telego.GetUpdatesParams{ params := telego.GetUpdatesParams{
Timeout: 30, // Increased timeout to reduce API calls Timeout: 20, // Reduced timeout to detect connection issues faster
} }
// Strict singleton: never start a second long-polling loop. // Strict singleton: never start a second long-polling loop.
tgBotMutex.Lock() tgBotMutex.Lock()
@@ -407,7 +447,7 @@ func (t *Tgbot) OnReceive() {
botWG.Add(1) botWG.Add(1)
tgBotMutex.Unlock() tgBotMutex.Unlock()
// Get updates channel using the context. // Get updates channel using the context with shorter timeout for better error recovery
updates, _ := bot.UpdatesViaLongPolling(ctx, &params) updates, _ := bot.UpdatesViaLongPolling(ctx, &params)
go func() { go func() {
defer botWG.Done() defer botWG.Done()
@@ -613,7 +653,7 @@ func (t *Tgbot) answerCommand(message *telego.Message, chatId int64, isAdmin boo
msg += t.I18nBot("tgbot.commands.help") msg += t.I18nBot("tgbot.commands.help")
msg += t.I18nBot("tgbot.commands.pleaseChoose") msg += t.I18nBot("tgbot.commands.pleaseChoose")
case "start": case "start":
msg += t.I18nBot("tgbot.commands.start", "Firstname=="+message.From.FirstName) msg += t.I18nBot("tgbot.commands.start", "Firstname=="+html.EscapeString(message.From.FirstName))
if isAdmin { if isAdmin {
msg += t.I18nBot("tgbot.commands.welcome", "Hostname=="+hostname) msg += t.I18nBot("tgbot.commands.welcome", "Hostname=="+hostname)
} }
@@ -1886,6 +1926,8 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
} else { } else {
t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.successfulOperation"), tu.ReplyKeyboardRemove()) t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.successfulOperation"), tu.ReplyKeyboardRemove())
t.sendClientIndividualLinks(chatId, client_Email)
t.sendClientQRLinks(chatId, client_Email)
} }
case "add_client_submit_enable": case "add_client_submit_enable":
client_Enable = true client_Enable = true
@@ -1896,6 +1938,8 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
} else { } else {
t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.successfulOperation"), tu.ReplyKeyboardRemove()) t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.successfulOperation"), tu.ReplyKeyboardRemove())
t.sendClientIndividualLinks(chatId, client_Email)
t.sendClientQRLinks(chatId, client_Email)
} }
case "reset_all_traffics_cancel": case "reset_all_traffics_cancel":
t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
@@ -2246,10 +2290,36 @@ func (t *Tgbot) SendMsgToTgbot(chatId int64, msg string, replyMarkup ...telego.R
if len(replyMarkup) > 0 && n == (len(allMessages)-1) { if len(replyMarkup) > 0 && n == (len(allMessages)-1) {
params.ReplyMarkup = replyMarkup[0] params.ReplyMarkup = replyMarkup[0]
} }
_, err := bot.SendMessage(context.Background(), &params)
if err != nil { // Retry logic with exponential backoff for connection errors
logger.Warning("Error sending telegram message :", err) maxRetries := 3
for attempt := range maxRetries {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
_, err := bot.SendMessage(ctx, &params)
cancel()
if err == nil {
break // Success
}
// Check if error is a connection error
errStr := err.Error()
isConnectionError := strings.Contains(errStr, "connection") ||
strings.Contains(errStr, "timeout") ||
strings.Contains(errStr, "closed")
if isConnectionError && attempt < maxRetries-1 {
// Exponential backoff: 1s, 2s, 4s
backoff := time.Duration(1<<uint(attempt)) * time.Second
logger.Warningf("Connection error sending telegram message (attempt %d/%d), retrying in %v: %v",
attempt+1, maxRetries, backoff, err)
time.Sleep(backoff)
} else {
logger.Warning("Error sending telegram message:", err)
break
}
} }
// Reduced delay to improve performance (only needed for rate limiting) // Reduced delay to improve performance (only needed for rate limiting)
if n < len(allMessages)-1 { // Only delay between messages, not after the last one if n < len(allMessages)-1 { // Only delay between messages, not after the last one
time.Sleep(100 * time.Millisecond) time.Sleep(100 * time.Millisecond)
@@ -2322,9 +2392,9 @@ func (t *Tgbot) buildSubscriptionURLs(email string) (string, string, error) {
// If pre-configured URIs are available, use them directly // If pre-configured URIs are available, use them directly
if subURI != "" { if subURI != "" {
if !strings.HasSuffix(subURI, "/") { if !strings.HasSuffix(subURI, "/") {
subURI = subURI + "/" subURI = subURI + "/"
} }
subURL = fmt.Sprintf("%s%s", subURI, client.SubID) subURL = fmt.Sprintf("%s%s", subURI, client.SubID)
} else { } else {
subURL = fmt.Sprintf("%s://%s%s%s", scheme, host, subPath, client.SubID) subURL = fmt.Sprintf("%s://%s%s%s", scheme, host, subPath, client.SubID)
} }
@@ -2333,7 +2403,7 @@ func (t *Tgbot) buildSubscriptionURLs(email string) (string, string, error) {
if !strings.HasSuffix(subJsonURI, "/") { if !strings.HasSuffix(subJsonURI, "/") {
subJsonURI = subJsonURI + "/" subJsonURI = subJsonURI + "/"
} }
subJsonURL = fmt.Sprintf("%s%s", subJsonURI, client.SubID) subJsonURL = fmt.Sprintf("%s%s", subJsonURI, client.SubID)
} else { } else {
subJsonURL = fmt.Sprintf("%s://%s%s%s", scheme, host, subJsonPath, client.SubID) subJsonURL = fmt.Sprintf("%s://%s%s%s", scheme, host, subJsonPath, client.SubID)
@@ -2584,8 +2654,12 @@ func (t *Tgbot) SendBackupToAdmins() {
if !t.IsRunning() { if !t.IsRunning() {
return return
} }
for _, adminId := range adminIds { for i, adminId := range adminIds {
t.sendBackup(int64(adminId)) t.sendBackup(int64(adminId))
// Add delay between sends to avoid Telegram rate limits
if i < len(adminIds)-1 {
time.Sleep(1 * time.Second)
}
} }
} }
@@ -2650,7 +2724,7 @@ func (t *Tgbot) prepareServerUsageInfo() string {
info += t.I18nBot("tgbot.messages.ip", "IP=="+t.I18nBot("tgbot.unknown")) info += t.I18nBot("tgbot.messages.ip", "IP=="+t.I18nBot("tgbot.unknown"))
info += "\r\n" info += "\r\n"
} else { } else {
for i := 0; i < len(netInterfaces); i++ { for i := range netInterfaces {
if (netInterfaces[i].Flags & net.FlagUp) != 0 { if (netInterfaces[i].Flags & net.FlagUp) != 0 {
addrs, _ := netInterfaces[i].Addrs() addrs, _ := netInterfaces[i].Addrs()
@@ -2719,29 +2793,29 @@ func (t *Tgbot) UserLoginNotify(username string, password string, ip string, tim
// getInboundUsages retrieves and formats inbound usage information. // getInboundUsages retrieves and formats inbound usage information.
func (t *Tgbot) getInboundUsages() string { func (t *Tgbot) getInboundUsages() string {
info := "" var info strings.Builder
// get traffic // get traffic
inbounds, err := t.inboundService.GetAllInbounds() inbounds, err := t.inboundService.GetAllInbounds()
if err != nil { if err != nil {
logger.Warning("GetAllInbounds run failed:", err) logger.Warning("GetAllInbounds run failed:", err)
info += t.I18nBot("tgbot.answers.getInboundsFailed") info.WriteString(t.I18nBot("tgbot.answers.getInboundsFailed"))
} else { } else {
// NOTE:If there no any sessions here,need to notify here // NOTE:If there no any sessions here,need to notify here
// TODO:Sub-node push, automatic conversion format // TODO:Sub-node push, automatic conversion format
for _, inbound := range inbounds { for _, inbound := range inbounds {
info += t.I18nBot("tgbot.messages.inbound", "Remark=="+inbound.Remark) info.WriteString(t.I18nBot("tgbot.messages.inbound", "Remark=="+inbound.Remark))
info += t.I18nBot("tgbot.messages.port", "Port=="+strconv.Itoa(inbound.Port)) info.WriteString(t.I18nBot("tgbot.messages.port", "Port=="+strconv.Itoa(inbound.Port)))
info += t.I18nBot("tgbot.messages.traffic", "Total=="+common.FormatTraffic((inbound.Up+inbound.Down)), "Upload=="+common.FormatTraffic(inbound.Up), "Download=="+common.FormatTraffic(inbound.Down)) info.WriteString(t.I18nBot("tgbot.messages.traffic", "Total=="+common.FormatTraffic((inbound.Up+inbound.Down)), "Upload=="+common.FormatTraffic(inbound.Up), "Download=="+common.FormatTraffic(inbound.Down)))
if inbound.ExpiryTime == 0 { if inbound.ExpiryTime == 0 {
info += t.I18nBot("tgbot.messages.expire", "Time=="+t.I18nBot("tgbot.unlimited")) info.WriteString(t.I18nBot("tgbot.messages.expire", "Time=="+t.I18nBot("tgbot.unlimited")))
} else { } else {
info += t.I18nBot("tgbot.messages.expire", "Time=="+time.Unix((inbound.ExpiryTime/1000), 0).Format("2006-01-02 15:04:05")) info.WriteString(t.I18nBot("tgbot.messages.expire", "Time=="+time.Unix((inbound.ExpiryTime/1000), 0).Format("2006-01-02 15:04:05")))
} }
info += "\r\n" info.WriteString("\r\n")
} }
} }
return info return info.String()
} }
// getInbounds creates an inline keyboard with all inbounds. // getInbounds creates an inline keyboard with all inbounds.
@@ -2991,12 +3065,9 @@ func (t *Tgbot) clientInfoMsg(
status := t.I18nBot("tgbot.offline") status := t.I18nBot("tgbot.offline")
isOnline := false isOnline := false
if p.IsRunning() { if p.IsRunning() {
for _, online := range p.GetOnlineClients() { if slices.Contains(p.GetOnlineClients(), traffic.Email) {
if online == traffic.Email { status = t.I18nBot("tgbot.online")
status = t.I18nBot("tgbot.online") isOnline = true
isOnline = true
break
}
} }
} }
@@ -3083,9 +3154,41 @@ func (t *Tgbot) searchClientIps(chatId int64, email string, messageID ...int) {
ips = t.I18nBot("tgbot.noIpRecord") ips = t.I18nBot("tgbot.noIpRecord")
} }
formattedIps := ips
if err == nil && len(ips) > 0 {
type ipWithTimestamp struct {
IP string `json:"ip"`
Timestamp int64 `json:"timestamp"`
}
var ipsWithTime []ipWithTimestamp
if json.Unmarshal([]byte(ips), &ipsWithTime) == nil && len(ipsWithTime) > 0 {
lines := make([]string, 0, len(ipsWithTime))
for _, item := range ipsWithTime {
if item.IP == "" {
continue
}
if item.Timestamp > 0 {
ts := time.Unix(item.Timestamp, 0).Format("2006-01-02 15:04:05")
lines = append(lines, fmt.Sprintf("%s (%s)", item.IP, ts))
continue
}
lines = append(lines, item.IP)
}
if len(lines) > 0 {
formattedIps = strings.Join(lines, "\n")
}
} else {
var oldIps []string
if json.Unmarshal([]byte(ips), &oldIps) == nil && len(oldIps) > 0 {
formattedIps = strings.Join(oldIps, "\n")
}
}
}
output := "" output := ""
output += t.I18nBot("tgbot.messages.email", "Email=="+email) output += t.I18nBot("tgbot.messages.email", "Email=="+email)
output += t.I18nBot("tgbot.messages.ips", "IPs=="+ips) output += t.I18nBot("tgbot.messages.ips", "IPs=="+formattedIps)
output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05")) output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
inlineKeyboard := tu.InlineKeyboard( inlineKeyboard := tu.InlineKeyboard(
@@ -3203,6 +3306,27 @@ func (t *Tgbot) searchClient(chatId int64, email string, messageID ...int) {
} }
} }
// getCommonClientButtons returns the shared inline keyboard rows for client configuration
func (t *Tgbot) getCommonClientButtons() [][]telego.InlineKeyboardButton {
return [][]telego.InlineKeyboardButton{
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.limitTraffic")).WithCallbackData("add_client_ch_default_traffic"),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.resetExpire")).WithCallbackData("add_client_ch_default_exp"),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_comment")).WithCallbackData("add_client_ch_default_comment"),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.ipLimit")).WithCallbackData("add_client_ch_default_ip_limit"),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitDisable")).WithCallbackData("add_client_submit_disable"),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitEnable")).WithCallbackData("add_client_submit_enable"),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData("add_client_cancel"),
),
}
}
// addClient handles the process of adding a new client to an inbound. // addClient handles the process of adding a new client to an inbound.
func (t *Tgbot) addClient(chatId int64, msg string, messageID ...int) { func (t *Tgbot) addClient(chatId int64, msg string, messageID ...int) {
inbound, err := t.inboundService.GetInbound(receiver_inbound_ID) inbound, err := t.inboundService.GetInbound(receiver_inbound_ID)
@@ -3213,91 +3337,40 @@ func (t *Tgbot) addClient(chatId int64, msg string, messageID ...int) {
protocol := inbound.Protocol protocol := inbound.Protocol
var protocolRows [][]telego.InlineKeyboardButton
switch protocol { switch protocol {
case model.VMESS, model.VLESS: case model.VMESS, model.VLESS:
inlineKeyboard := tu.InlineKeyboard( protocolRows = [][]telego.InlineKeyboardButton{
tu.InlineKeyboardRow( tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"), tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_id")).WithCallbackData("add_client_ch_default_id"), tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_id")).WithCallbackData("add_client_ch_default_id"),
), ),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.limitTraffic")).WithCallbackData("add_client_ch_default_traffic"),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.resetExpire")).WithCallbackData("add_client_ch_default_exp"),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_comment")).WithCallbackData("add_client_ch_default_comment"),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.ipLimit")).WithCallbackData("add_client_ch_default_ip_limit"),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitDisable")).WithCallbackData("add_client_submit_disable"),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitEnable")).WithCallbackData("add_client_submit_enable"),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData("add_client_cancel"),
),
)
if len(messageID) > 0 {
t.editMessageTgBot(chatId, messageID[0], msg, inlineKeyboard)
} else {
t.SendMsgToTgbot(chatId, msg, inlineKeyboard)
} }
case model.Trojan: case model.Trojan:
inlineKeyboard := tu.InlineKeyboard( protocolRows = [][]telego.InlineKeyboardButton{
tu.InlineKeyboardRow( tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"), tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_password")).WithCallbackData("add_client_ch_default_pass_tr"), tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_password")).WithCallbackData("add_client_ch_default_pass_tr"),
), ),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.limitTraffic")).WithCallbackData("add_client_ch_default_traffic"),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.resetExpire")).WithCallbackData("add_client_ch_default_exp"),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_comment")).WithCallbackData("add_client_ch_default_comment"),
tu.InlineKeyboardButton("ip limit").WithCallbackData("add_client_ch_default_ip_limit"),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitDisable")).WithCallbackData("add_client_submit_disable"),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitEnable")).WithCallbackData("add_client_submit_enable"),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData("add_client_cancel"),
),
)
if len(messageID) > 0 {
t.editMessageTgBot(chatId, messageID[0], msg, inlineKeyboard)
} else {
t.SendMsgToTgbot(chatId, msg, inlineKeyboard)
} }
case model.Shadowsocks: case model.Shadowsocks:
inlineKeyboard := tu.InlineKeyboard( protocolRows = [][]telego.InlineKeyboardButton{
tu.InlineKeyboardRow( tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"), tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_password")).WithCallbackData("add_client_ch_default_pass_sh"), tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_password")).WithCallbackData("add_client_ch_default_pass_sh"),
), ),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.limitTraffic")).WithCallbackData("add_client_ch_default_traffic"),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.resetExpire")).WithCallbackData("add_client_ch_default_exp"),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_comment")).WithCallbackData("add_client_ch_default_comment"),
tu.InlineKeyboardButton("ip limit").WithCallbackData("add_client_ch_default_ip_limit"),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitDisable")).WithCallbackData("add_client_submit_disable"),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitEnable")).WithCallbackData("add_client_submit_enable"),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData("add_client_cancel"),
),
)
if len(messageID) > 0 {
t.editMessageTgBot(chatId, messageID[0], msg, inlineKeyboard)
} else {
t.SendMsgToTgbot(chatId, msg, inlineKeyboard)
} }
} }
commonRows := t.getCommonClientButtons()
inlineKeyboard := tu.InlineKeyboard(append(protocolRows, commonRows...)...)
if len(messageID) > 0 {
t.editMessageTgBot(chatId, messageID[0], msg, inlineKeyboard)
} else {
t.SendMsgToTgbot(chatId, msg, inlineKeyboard)
}
} }
// searchInbound searches for inbounds by remark and sends the results. // searchInbound searches for inbounds by remark and sends the results.
@@ -3329,11 +3402,11 @@ func (t *Tgbot) searchInbound(chatId int64, remark string) {
t.SendMsgToTgbot(chatId, info) t.SendMsgToTgbot(chatId, info)
if len(inbound.ClientStats) > 0 { if len(inbound.ClientStats) > 0 {
output := "" var output strings.Builder
for _, traffic := range inbound.ClientStats { for _, traffic := range inbound.ClientStats {
output += t.clientInfoMsg(&traffic, true, true, true, true, true, true) output.WriteString(t.clientInfoMsg(&traffic, true, true, true, true, true, true))
} }
t.SendMsgToTgbot(chatId, output) t.SendMsgToTgbot(chatId, output.String())
} }
} }
} }
@@ -3563,13 +3636,17 @@ func (t *Tgbot) sendBackup(chatId int64) {
logger.Error("Error in trigger a checkpoint operation: ", err) logger.Error("Error in trigger a checkpoint operation: ", err)
} }
// Send database backup
file, err := os.Open(config.GetDBPath()) file, err := os.Open(config.GetDBPath())
if err == nil { if err == nil {
defer file.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
document := tu.Document( document := tu.Document(
tu.ID(chatId), tu.ID(chatId),
tu.File(file), tu.File(file),
) )
_, err = bot.SendDocument(context.Background(), document) _, err = bot.SendDocument(ctx, document)
if err != nil { if err != nil {
logger.Error("Error in uploading backup: ", err) logger.Error("Error in uploading backup: ", err)
} }
@@ -3577,13 +3654,20 @@ func (t *Tgbot) sendBackup(chatId int64) {
logger.Error("Error in opening db file for backup: ", err) logger.Error("Error in opening db file for backup: ", err)
} }
// Small delay between file sends
time.Sleep(500 * time.Millisecond)
// Send config.json backup
file, err = os.Open(xray.GetConfigPath()) file, err = os.Open(xray.GetConfigPath())
if err == nil { if err == nil {
defer file.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
document := tu.Document( document := tu.Document(
tu.ID(chatId), tu.ID(chatId),
tu.File(file), tu.File(file),
) )
_, err = bot.SendDocument(context.Background(), document) _, err = bot.SendDocument(ctx, document)
if err != nil { if err != nil {
logger.Error("Error in uploading config.json: ", err) logger.Error("Error in uploading config.json: ", err)
} }

View File

@@ -33,7 +33,7 @@ func (s *UserService) GetFirstUser() (*model.User, error) {
return user, nil return user, nil
} }
func (s *UserService) CheckUser(username string, password string, twoFactorCode string) *model.User { func (s *UserService) CheckUser(username string, password string, twoFactorCode string) (*model.User, error) {
db := database.GetDB() db := database.GetDB()
user := &model.User{} user := &model.User{}
@@ -43,17 +43,16 @@ func (s *UserService) CheckUser(username string, password string, twoFactorCode
First(user). First(user).
Error Error
if err == gorm.ErrRecordNotFound { if err == gorm.ErrRecordNotFound {
return nil return nil, errors.New("invalid credentials")
} else if err != nil { } else if err != nil {
logger.Warning("check user err:", err) logger.Warning("check user err:", err)
return nil return nil, err
} }
// If LDAP enabled and local password check fails, attempt LDAP auth
if !crypto.CheckPasswordHash(user.Password, password) { if !crypto.CheckPasswordHash(user.Password, password) {
ldapEnabled, _ := s.settingService.GetLdapEnable() ldapEnabled, _ := s.settingService.GetLdapEnable()
if !ldapEnabled { if !ldapEnabled {
return nil return nil, errors.New("invalid credentials")
} }
host, _ := s.settingService.GetLdapHost() host, _ := s.settingService.GetLdapHost()
@@ -77,15 +76,14 @@ func (s *UserService) CheckUser(username string, password string, twoFactorCode
} }
ok, err := ldaputil.AuthenticateUser(cfg, username, password) ok, err := ldaputil.AuthenticateUser(cfg, username, password)
if err != nil || !ok { if err != nil || !ok {
return nil return nil, errors.New("invalid credentials")
} }
// On successful LDAP auth, continue 2FA checks below
} }
twoFactorEnable, err := s.settingService.GetTwoFactorEnable() twoFactorEnable, err := s.settingService.GetTwoFactorEnable()
if err != nil { if err != nil {
logger.Warning("check two factor err:", err) logger.Warning("check two factor err:", err)
return nil return nil, err
} }
if twoFactorEnable { if twoFactorEnable {
@@ -93,15 +91,15 @@ func (s *UserService) CheckUser(username string, password string, twoFactorCode
if err != nil { if err != nil {
logger.Warning("check two factor token err:", err) logger.Warning("check two factor token err:", err)
return nil return nil, err
} }
if gotp.NewDefaultTOTP(twoFactorToken).Now() != twoFactorCode { if gotp.NewDefaultTOTP(twoFactorToken).Now() != twoFactorCode {
return nil return nil, errors.New("invalid 2fa code")
} }
} }
return user return user, nil
} }
func (s *UserService) UpdateUser(id int, username string, password string) error { func (s *UserService) UpdateUser(id int, username string, password string) error {

View File

@@ -460,6 +460,8 @@
"FreedomStrategyDesc" = "اختار استراتيجية المخرجات للشبكة في بروتوكول الحرية." "FreedomStrategyDesc" = "اختار استراتيجية المخرجات للشبكة في بروتوكول الحرية."
"RoutingStrategy" = "استراتيجية التوجيه العامة" "RoutingStrategy" = "استراتيجية التوجيه العامة"
"RoutingStrategyDesc" = "حدد استراتيجية التوجيه الإجمالية لحل كل الطلبات." "RoutingStrategyDesc" = "حدد استراتيجية التوجيه الإجمالية لحل كل الطلبات."
"outboundTestUrl" = "رابط اختبار المخرج"
"outboundTestUrlDesc" = "الرابط المستخدم عند اختبار اتصال المخرج"
"Torrent" = "حظر بروتوكول التورنت" "Torrent" = "حظر بروتوكول التورنت"
"Inbounds" = "الإدخالات" "Inbounds" = "الإدخالات"
"InboundsDesc" = "قبول العملاء المعينين." "InboundsDesc" = "قبول العملاء المعينين."
@@ -523,6 +525,12 @@
"accountInfo" = "معلومات الحساب" "accountInfo" = "معلومات الحساب"
"outboundStatus" = "حالة المخرج" "outboundStatus" = "حالة المخرج"
"sendThrough" = "أرسل من خلال" "sendThrough" = "أرسل من خلال"
"test" = "اختبار"
"testResult" = "نتيجة الاختبار"
"testing" = "جاري اختبار الاتصال..."
"testSuccess" = "الاختبار ناجح"
"testFailed" = "فشل الاختبار"
"testError" = "فشل اختبار المخرج"
[pages.xray.balancer] [pages.xray.balancer]
"addBalancer" = "أضف موازن تحميل" "addBalancer" = "أضف موازن تحميل"
@@ -655,6 +663,7 @@
"userSaved" = "✅ حفظت بيانات مستخدم Telegram." "userSaved" = "✅ حفظت بيانات مستخدم Telegram."
"loginSuccess" = "✅ تسجيل الدخول للبانل تم بنجاح.\r\n" "loginSuccess" = "✅ تسجيل الدخول للبانل تم بنجاح.\r\n"
"loginFailed" = "❗️فشل محاولة تسجيل الدخول للبانل.\r\n" "loginFailed" = "❗️فشل محاولة تسجيل الدخول للبانل.\r\n"
"2faFailed" = "فشل 2FA"
"report" = "🕰 التقارير المجدولة: {{ .RunTime }}\r\n" "report" = "🕰 التقارير المجدولة: {{ .RunTime }}\r\n"
"datetime" = "⏰ التاريخ والوقت: {{ .DateTime }}\r\n" "datetime" = "⏰ التاريخ والوقت: {{ .DateTime }}\r\n"
"hostname" = "💻 السيرفر: {{ .Hostname }}\r\n" "hostname" = "💻 السيرفر: {{ .Hostname }}\r\n"

View File

@@ -460,6 +460,8 @@
"FreedomStrategyDesc" = "Set the output strategy for the network in the Freedom Protocol." "FreedomStrategyDesc" = "Set the output strategy for the network in the Freedom Protocol."
"RoutingStrategy" = "Overall Routing Strategy" "RoutingStrategy" = "Overall Routing Strategy"
"RoutingStrategyDesc" = "Set the overall traffic routing strategy for resolving all requests." "RoutingStrategyDesc" = "Set the overall traffic routing strategy for resolving all requests."
"outboundTestUrl" = "Outbound Test URL"
"outboundTestUrlDesc" = "URL used when testing outbound connectivity."
"Torrent" = "Block BitTorrent Protocol" "Torrent" = "Block BitTorrent Protocol"
"Inbounds" = "Inbounds" "Inbounds" = "Inbounds"
"InboundsDesc" = "Accepting the specific clients." "InboundsDesc" = "Accepting the specific clients."
@@ -523,6 +525,12 @@
"accountInfo" = "Account Information" "accountInfo" = "Account Information"
"outboundStatus" = "Outbound Status" "outboundStatus" = "Outbound Status"
"sendThrough" = "Send Through" "sendThrough" = "Send Through"
"test" = "Test"
"testResult" = "Test Result"
"testing" = "Testing connection..."
"testSuccess" = "Test successful"
"testFailed" = "Test failed"
"testError" = "Failed to test outbound"
[pages.xray.balancer] [pages.xray.balancer]
"addBalancer" = "Add Balancer" "addBalancer" = "Add Balancer"
@@ -655,6 +663,7 @@
"userSaved" = "✅ Telegram User saved." "userSaved" = "✅ Telegram User saved."
"loginSuccess" = "✅ Logged in to the panel successfully.\r\n" "loginSuccess" = "✅ Logged in to the panel successfully.\r\n"
"loginFailed" = "❗Login attempt to the panel failed.\r\n" "loginFailed" = "❗Login attempt to the panel failed.\r\n"
"2faFailed" = "2FA Failed"
"report" = "🕰 Scheduled Reports: {{ .RunTime }}\r\n" "report" = "🕰 Scheduled Reports: {{ .RunTime }}\r\n"
"datetime" = "⏰ Date&Time: {{ .DateTime }}\r\n" "datetime" = "⏰ Date&Time: {{ .DateTime }}\r\n"
"hostname" = "💻 Host: {{ .Hostname }}\r\n" "hostname" = "💻 Host: {{ .Hostname }}\r\n"

View File

@@ -9,7 +9,7 @@
"copy" = "Copiar" "copy" = "Copiar"
"copied" = "Copiado" "copied" = "Copiado"
"download" = "Descargar" "download" = "Descargar"
"remark" = "Nota" "remark" = "Notas"
"enable" = "Habilitar" "enable" = "Habilitar"
"protocol" = "Protocolo" "protocol" = "Protocolo"
"search" = "Buscar" "search" = "Buscar"
@@ -28,14 +28,14 @@
"edit" = "Editar" "edit" = "Editar"
"delete" = "Eliminar" "delete" = "Eliminar"
"reset" = "Restablecer" "reset" = "Restablecer"
"noData" = "Sin datos." "noData" = "Sin datos"
"copySuccess" = "Copiado exitosamente" "copySuccess" = "Copiado exitosamente"
"sure" = "Seguro" "sure" = "Seguro"
"encryption" = "Encriptación" "encryption" = "Encriptación"
"useIPv4ForHost" = "Usar IPv4 para el host" "useIPv4ForHost" = "Usar IPv4 para el host"
"transmission" = "Transmisión" "transmission" = "Transmisión"
"host" = "Anfitrión" "host" = "Host"
"path" = "Ruta" "path" = "Path"
"camouflage" = "Camuflaje" "camouflage" = "Camuflaje"
"status" = "Estado" "status" = "Estado"
"enabled" = "Habilitado" "enabled" = "Habilitado"
@@ -114,7 +114,7 @@
"cpu" = "CPU" "cpu" = "CPU"
"logicalProcessors" = "Procesadores lógicos" "logicalProcessors" = "Procesadores lógicos"
"frequency" = "Frecuencia" "frequency" = "Frecuencia"
"swap" = "Intercambio" "swap" = "Memoria Virtual"
"storage" = "Almacenamiento" "storage" = "Almacenamiento"
"memory" = "RAM" "memory" = "RAM"
"threads" = "Hilos" "threads" = "Hilos"
@@ -167,7 +167,7 @@
[pages.inbounds] [pages.inbounds]
"allTimeTraffic" = "Tráfico Total" "allTimeTraffic" = "Tráfico Total"
"allTimeTrafficUsage" = "Uso total de todos los tiempos" "allTimeTrafficUsage" = "Uso de datos histórico"
"title" = "Entradas" "title" = "Entradas"
"totalDownUp" = "Subidas/Descargas Totales" "totalDownUp" = "Subidas/Descargas Totales"
"totalUsage" = "Uso Total" "totalUsage" = "Uso Total"
@@ -201,7 +201,7 @@
"destinationPort" = "Puerto de Destino" "destinationPort" = "Puerto de Destino"
"targetAddress" = "Dirección de Destino" "targetAddress" = "Dirección de Destino"
"monitorDesc" = "Dejar en blanco por defecto" "monitorDesc" = "Dejar en blanco por defecto"
"meansNoLimit" = "= illimitata. (unidad: GB)" "meansNoLimit" = " = illimitata. (unidad: GB)"
"totalFlow" = "Flujo Total" "totalFlow" = "Flujo Total"
"leaveBlankToNeverExpire" = "Dejar en Blanco para Nunca Expirar" "leaveBlankToNeverExpire" = "Dejar en Blanco para Nunca Expirar"
"noRecommendKeepDefault" = "No hay requisitos especiales para mantener la configuración predeterminada" "noRecommendKeepDefault" = "No hay requisitos especiales para mantener la configuración predeterminada"
@@ -283,7 +283,7 @@
"inboundClientAddSuccess" = "Cliente(s) de entrada añadido(s)" "inboundClientAddSuccess" = "Cliente(s) de entrada añadido(s)"
"inboundClientDeleteSuccess" = "Cliente de entrada eliminado" "inboundClientDeleteSuccess" = "Cliente de entrada eliminado"
"inboundClientUpdateSuccess" = "Cliente de entrada actualizado" "inboundClientUpdateSuccess" = "Cliente de entrada actualizado"
"delDepletedClientsSuccess" = "Todos los clientes agotados fueron eliminados" "delDepletedClientsSuccess" = "Todos los clientes con tráfico agotado fueron eliminados"
"resetAllClientTrafficSuccess" = "Todo el tráfico del cliente ha sido reiniciado" "resetAllClientTrafficSuccess" = "Todo el tráfico del cliente ha sido reiniciado"
"resetAllTrafficSuccess" = "Todo el tráfico ha sido reiniciado" "resetAllTrafficSuccess" = "Todo el tráfico ha sido reiniciado"
"resetInboundClientTrafficSuccess" = "El tráfico ha sido reiniciado" "resetInboundClientTrafficSuccess" = "El tráfico ha sido reiniciado"
@@ -373,7 +373,7 @@
"subEnableDesc" = "Función de suscripción con configuración separada." "subEnableDesc" = "Función de suscripción con configuración separada."
"subJsonEnable" = "Habilitar/Deshabilitar el endpoint de suscripción JSON de forma independiente." "subJsonEnable" = "Habilitar/Deshabilitar el endpoint de suscripción JSON de forma independiente."
"subTitle" = "Título de la Suscripción" "subTitle" = "Título de la Suscripción"
"subTitleDesc" = "Título mostrado en el cliente de VPN" "subTitleDesc" = "Título mostrado en el cliente VPN"
"subSupportUrl" = "URL de soporte" "subSupportUrl" = "URL de soporte"
"subSupportUrlDesc" = "Enlace de soporte técnico mostrado en el cliente VPN" "subSupportUrlDesc" = "Enlace de soporte técnico mostrado en el cliente VPN"
"subProfileUrl" = "URL del perfil" "subProfileUrl" = "URL del perfil"
@@ -411,8 +411,8 @@
"fragment" = "Fragmentación" "fragment" = "Fragmentación"
"fragmentDesc" = "Habilitar la fragmentación para el paquete de saludo de TLS" "fragmentDesc" = "Habilitar la fragmentación para el paquete de saludo de TLS"
"fragmentSett" = "Configuración de Fragmentación" "fragmentSett" = "Configuración de Fragmentación"
"noisesDesc" = "Activar Noises." "noisesDesc" = "Activar Sonidos"
"noisesSett" = "Configuración de Noises" "noisesSett" = "Configuración de Sonidos"
"mux" = "Mux" "mux" = "Mux"
"muxDesc" = "Transmite múltiples flujos de datos independientes dentro de un flujo de datos establecido." "muxDesc" = "Transmite múltiples flujos de datos independientes dentro de un flujo de datos establecido."
"muxSett" = "Configuración Mux" "muxSett" = "Configuración Mux"
@@ -436,8 +436,8 @@
"stopSuccess" = "Xray se ha detenido correctamente" "stopSuccess" = "Xray se ha detenido correctamente"
"restartError" = "Ocurrió un error al reiniciar Xray." "restartError" = "Ocurrió un error al reiniciar Xray."
"stopError" = "Ocurrió un error al detener Xray." "stopError" = "Ocurrió un error al detener Xray."
"basicTemplate" = "Plantilla Básica" "basicTemplate" = "Perfil Básico"
"advancedTemplate" = "Plantilla Avanzada" "advancedTemplate" = "Perfil Avanzado"
"generalConfigs" = "Configuraciones Generales" "generalConfigs" = "Configuraciones Generales"
"generalConfigsDesc" = "Estas opciones proporcionarán ajustes generales." "generalConfigsDesc" = "Estas opciones proporcionarán ajustes generales."
"logConfigs" = "Registro" "logConfigs" = "Registro"
@@ -460,6 +460,8 @@
"FreedomStrategyDesc" = "Establece la estrategia de salida de la red en el Protocolo Freedom." "FreedomStrategyDesc" = "Establece la estrategia de salida de la red en el Protocolo Freedom."
"RoutingStrategy" = "Configurar Estrategia de Enrutamiento de Dominios" "RoutingStrategy" = "Configurar Estrategia de Enrutamiento de Dominios"
"RoutingStrategyDesc" = "Establece la estrategia general de enrutamiento para la resolución de DNS." "RoutingStrategyDesc" = "Establece la estrategia general de enrutamiento para la resolución de DNS."
"outboundTestUrl" = "URL de prueba de outbound"
"outboundTestUrlDesc" = "URL usada al probar la conectividad del outbound"
"Torrent" = "Prohibir Uso de BitTorrent" "Torrent" = "Prohibir Uso de BitTorrent"
"Inbounds" = "Entrante" "Inbounds" = "Entrante"
"InboundsDesc" = "Cambia la plantilla de configuración para aceptar clientes específicos." "InboundsDesc" = "Cambia la plantilla de configuración para aceptar clientes específicos."
@@ -523,6 +525,12 @@
"accountInfo" = "Información de la Cuenta" "accountInfo" = "Información de la Cuenta"
"outboundStatus" = "Estado de Salida" "outboundStatus" = "Estado de Salida"
"sendThrough" = "Enviar a través de" "sendThrough" = "Enviar a través de"
"test" = "Probar"
"testResult" = "Resultado de la prueba"
"testing" = "Probando conexión..."
"testSuccess" = "Prueba exitosa"
"testFailed" = "Prueba fallida"
"testError" = "Error al probar la salida"
[pages.xray.balancer] [pages.xray.balancer]
"addBalancer" = "Agregar equilibrador" "addBalancer" = "Agregar equilibrador"
@@ -610,8 +618,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!"
@@ -655,6 +663,7 @@
"userSaved" = "✅ Usuario de Telegram guardado." "userSaved" = "✅ Usuario de Telegram guardado."
"loginSuccess" = "✅ Has iniciado sesión en el panel con éxito.\r\n" "loginSuccess" = "✅ Has iniciado sesión en el panel con éxito.\r\n"
"loginFailed" = "❗️ Falló el inicio de sesión en el panel.\r\n" "loginFailed" = "❗️ Falló el inicio de sesión en el panel.\r\n"
"2faFailed" = "Error de 2FA"
"report" = "🕰 Informes programados: {{ .RunTime }}\r\n" "report" = "🕰 Informes programados: {{ .RunTime }}\r\n"
"datetime" = "⏰ Fecha y Hora: {{ .DateTime }}\r\n" "datetime" = "⏰ Fecha y Hora: {{ .DateTime }}\r\n"
"hostname" = "💻 Nombre del Host: {{ .Hostname }}\r\n" "hostname" = "💻 Nombre del Host: {{ .Hostname }}\r\n"

View File

@@ -460,6 +460,8 @@
"FreedomStrategyDesc" = "تعیین می‌کند Freedom استراتژی خروجی شبکه را برای پروتکل" "FreedomStrategyDesc" = "تعیین می‌کند Freedom استراتژی خروجی شبکه را برای پروتکل"
"RoutingStrategy" = "استراتژی کلی مسیریابی" "RoutingStrategy" = "استراتژی کلی مسیریابی"
"RoutingStrategyDesc" = "استراتژی کلی مسیریابی برای حل تمام درخواست‌ها را تعیین می‌کند" "RoutingStrategyDesc" = "استراتژی کلی مسیریابی برای حل تمام درخواست‌ها را تعیین می‌کند"
"outboundTestUrl" = "آدرس تست خروجی"
"outboundTestUrlDesc" = "آدرسی که برای تست اتصال خروجی استفاده می‌شود."
"Torrent" = "مسدودسازی پروتکل بیت‌تورنت" "Torrent" = "مسدودسازی پروتکل بیت‌تورنت"
"Inbounds" = "ورودی‌ها" "Inbounds" = "ورودی‌ها"
"InboundsDesc" = "پذیرش کلاینت خاص" "InboundsDesc" = "پذیرش کلاینت خاص"
@@ -523,6 +525,12 @@
"accountInfo" = "اطلاعات حساب" "accountInfo" = "اطلاعات حساب"
"outboundStatus" = "وضعیت خروجی" "outboundStatus" = "وضعیت خروجی"
"sendThrough" = "ارسال با" "sendThrough" = "ارسال با"
"test" = "تست"
"testResult" = "نتیجه تست"
"testing" = "در حال تست اتصال..."
"testSuccess" = "تست موفقیت‌آمیز"
"testFailed" = "تست ناموفق"
"testError" = "خطا در تست خروجی"
[pages.xray.balancer] [pages.xray.balancer]
"addBalancer" = "افزودن بالانسر" "addBalancer" = "افزودن بالانسر"
@@ -655,6 +663,7 @@
"userSaved" = "✅ کاربر تلگرام ذخیره شد." "userSaved" = "✅ کاربر تلگرام ذخیره شد."
"loginSuccess" = "✅ با موفقیت به پنل وارد شدید.\r\n" "loginSuccess" = "✅ با موفقیت به پنل وارد شدید.\r\n"
"loginFailed" = "❗️ ورود به پنل ناموفق‌بود \r\n" "loginFailed" = "❗️ ورود به پنل ناموفق‌بود \r\n"
"2faFailed" = "خطای 2FA"
"report" = "🕰 گزارشات‌زمان‌بندی‌شده: {{ .RunTime }}\r\n" "report" = "🕰 گزارشات‌زمان‌بندی‌شده: {{ .RunTime }}\r\n"
"datetime" = "⏰ تاریخ‌وزمان: {{ .DateTime }}\r\n" "datetime" = "⏰ تاریخ‌وزمان: {{ .DateTime }}\r\n"
"hostname" = "💻 نام‌میزبان: {{ .Hostname }}\r\n" "hostname" = "💻 نام‌میزبان: {{ .Hostname }}\r\n"

View File

@@ -460,6 +460,8 @@
"FreedomStrategyDesc" = "Atur strategi output untuk jaringan dalam Protokol Freedom." "FreedomStrategyDesc" = "Atur strategi output untuk jaringan dalam Protokol Freedom."
"RoutingStrategy" = "Strategi Pengalihan Keseluruhan" "RoutingStrategy" = "Strategi Pengalihan Keseluruhan"
"RoutingStrategyDesc" = "Atur strategi pengalihan lalu lintas keseluruhan untuk menyelesaikan semua permintaan." "RoutingStrategyDesc" = "Atur strategi pengalihan lalu lintas keseluruhan untuk menyelesaikan semua permintaan."
"outboundTestUrl" = "URL tes outbound"
"outboundTestUrlDesc" = "URL yang digunakan saat menguji konektivitas outbound"
"Torrent" = "Blokir Protokol BitTorrent" "Torrent" = "Blokir Protokol BitTorrent"
"Inbounds" = "Masuk" "Inbounds" = "Masuk"
"InboundsDesc" = "Menerima klien tertentu." "InboundsDesc" = "Menerima klien tertentu."
@@ -523,6 +525,12 @@
"accountInfo" = "Informasi Akun" "accountInfo" = "Informasi Akun"
"outboundStatus" = "Status Keluar" "outboundStatus" = "Status Keluar"
"sendThrough" = "Kirim Melalui" "sendThrough" = "Kirim Melalui"
"test" = "Tes"
"testResult" = "Hasil Tes"
"testing" = "Menguji koneksi..."
"testSuccess" = "Tes berhasil"
"testFailed" = "Tes gagal"
"testError" = "Gagal menguji outbound"
[pages.xray.balancer] [pages.xray.balancer]
"addBalancer" = "Tambahkan Penyeimbang" "addBalancer" = "Tambahkan Penyeimbang"
@@ -655,6 +663,7 @@
"userSaved" = "✅ Pengguna Telegram tersimpan." "userSaved" = "✅ Pengguna Telegram tersimpan."
"loginSuccess" = "✅ Berhasil masuk ke panel.\r\n" "loginSuccess" = "✅ Berhasil masuk ke panel.\r\n"
"loginFailed" = "❗️ Gagal masuk ke panel.\r\n" "loginFailed" = "❗️ Gagal masuk ke panel.\r\n"
"2faFailed" = "2FA Gagal"
"report" = "🕰 Laporan Terjadwal: {{ .RunTime }}\r\n" "report" = "🕰 Laporan Terjadwal: {{ .RunTime }}\r\n"
"datetime" = "⏰ Tanggal & Waktu: {{ .DateTime }}\r\n" "datetime" = "⏰ Tanggal & Waktu: {{ .DateTime }}\r\n"
"hostname" = "💻 Host: {{ .Hostname }}\r\n" "hostname" = "💻 Host: {{ .Hostname }}\r\n"

View File

@@ -460,6 +460,8 @@
"FreedomStrategyDesc" = "Freedomプロトコル内のネットワークの出力戦略を設定する" "FreedomStrategyDesc" = "Freedomプロトコル内のネットワークの出力戦略を設定する"
"RoutingStrategy" = "ルーティングドメイン戦略設定" "RoutingStrategy" = "ルーティングドメイン戦略設定"
"RoutingStrategyDesc" = "DNS解決の全体的なルーティング戦略を設定する" "RoutingStrategyDesc" = "DNS解決の全体的なルーティング戦略を設定する"
"outboundTestUrl" = "アウトバウンドテスト URL"
"outboundTestUrlDesc" = "アウトバウンド接続テストに使用する URL。既定値"
"Torrent" = "BitTorrent プロトコルをブロック" "Torrent" = "BitTorrent プロトコルをブロック"
"Inbounds" = "インバウンドルール" "Inbounds" = "インバウンドルール"
"InboundsDesc" = "特定のクライアントからのトラフィックを受け入れる" "InboundsDesc" = "特定のクライアントからのトラフィックを受け入れる"
@@ -523,6 +525,12 @@
"accountInfo" = "アカウント情報" "accountInfo" = "アカウント情報"
"outboundStatus" = "アウトバウンドステータス" "outboundStatus" = "アウトバウンドステータス"
"sendThrough" = "送信経路" "sendThrough" = "送信経路"
"test" = "テスト"
"testResult" = "テスト結果"
"testing" = "接続をテスト中..."
"testSuccess" = "テスト成功"
"testFailed" = "テスト失敗"
"testError" = "アウトバウンドのテストに失敗しました"
[pages.xray.balancer] [pages.xray.balancer]
"addBalancer" = "負荷分散追加" "addBalancer" = "負荷分散追加"
@@ -655,6 +663,7 @@
"userSaved" = "✅ Telegramユーザーが保存されました。" "userSaved" = "✅ Telegramユーザーが保存されました。"
"loginSuccess" = "✅ パネルに正常にログインしました。\r\n" "loginSuccess" = "✅ パネルに正常にログインしました。\r\n"
"loginFailed" = "❗️ パネルのログインに失敗しました。\r\n" "loginFailed" = "❗️ パネルのログインに失敗しました。\r\n"
"2faFailed" = "2FAエラー"
"report" = "🕰 定期報告:{{ .RunTime }}\r\n" "report" = "🕰 定期報告:{{ .RunTime }}\r\n"
"datetime" = "⏰ 日時:{{ .DateTime }}\r\n" "datetime" = "⏰ 日時:{{ .DateTime }}\r\n"
"hostname" = "💻 ホスト名:{{ .Hostname }}\r\n" "hostname" = "💻 ホスト名:{{ .Hostname }}\r\n"

View File

@@ -460,6 +460,8 @@
"FreedomStrategyDesc" = "Definir a estratégia de saída para a rede no Protocolo Freedom." "FreedomStrategyDesc" = "Definir a estratégia de saída para a rede no Protocolo Freedom."
"RoutingStrategy" = "Estratégia Geral de Roteamento" "RoutingStrategy" = "Estratégia Geral de Roteamento"
"RoutingStrategyDesc" = "Definir a estratégia geral de roteamento de tráfego para resolver todas as solicitações." "RoutingStrategyDesc" = "Definir a estratégia geral de roteamento de tráfego para resolver todas as solicitações."
"outboundTestUrl" = "URL de teste de outbound"
"outboundTestUrlDesc" = "URL usada ao testar conectividade do outbound"
"Torrent" = "Bloquear Protocolo BitTorrent" "Torrent" = "Bloquear Protocolo BitTorrent"
"Inbounds" = "Inbounds" "Inbounds" = "Inbounds"
"InboundsDesc" = "Aceitar clientes específicos." "InboundsDesc" = "Aceitar clientes específicos."
@@ -523,6 +525,12 @@
"accountInfo" = "Informações da Conta" "accountInfo" = "Informações da Conta"
"outboundStatus" = "Status de Saída" "outboundStatus" = "Status de Saída"
"sendThrough" = "Enviar Através de" "sendThrough" = "Enviar Através de"
"test" = "Testar"
"testResult" = "Resultado do teste"
"testing" = "Testando conexão..."
"testSuccess" = "Teste bem-sucedido"
"testFailed" = "Teste falhou"
"testError" = "Falha ao testar saída"
[pages.xray.balancer] [pages.xray.balancer]
"addBalancer" = "Adicionar Balanceador" "addBalancer" = "Adicionar Balanceador"
@@ -655,6 +663,7 @@
"userSaved" = "✅ Usuário do Telegram salvo." "userSaved" = "✅ Usuário do Telegram salvo."
"loginSuccess" = "✅ Conectado ao painel com sucesso.\r\n" "loginSuccess" = "✅ Conectado ao painel com sucesso.\r\n"
"loginFailed" = "❗Tentativa de login no painel falhou.\r\n" "loginFailed" = "❗Tentativa de login no painel falhou.\r\n"
"2faFailed" = "Falha no 2FA"
"report" = "🕰 Relatórios agendados: {{ .RunTime }}\r\n" "report" = "🕰 Relatórios agendados: {{ .RunTime }}\r\n"
"datetime" = "⏰ Data&Hora: {{ .DateTime }}\r\n" "datetime" = "⏰ Data&Hora: {{ .DateTime }}\r\n"
"hostname" = "💻 Host: {{ .Hostname }}\r\n" "hostname" = "💻 Host: {{ .Hostname }}\r\n"

View File

@@ -149,7 +149,7 @@
"geofileUpdateDialogDesc" = "Это обновит файл #filename#." "geofileUpdateDialogDesc" = "Это обновит файл #filename#."
"geofilesUpdateDialogDesc" = "Это обновит все геофайлы." "geofilesUpdateDialogDesc" = "Это обновит все геофайлы."
"geofilesUpdateAll" = "Обновить все" "geofilesUpdateAll" = "Обновить все"
"geofileUpdatePopover" = "Геофайл успешно обновлён" "geofileUpdatePopover" = "Геофайлы успешно обновлены"
"dontRefresh" = "Установка в процессе. Не обновляйте страницу" "dontRefresh" = "Установка в процессе. Не обновляйте страницу"
"logs" = "Журнал" "logs" = "Журнал"
"config" = "Конфигурация" "config" = "Конфигурация"
@@ -460,6 +460,8 @@
"FreedomStrategyDesc" = "Установка стратегии вывода сети в протоколе Freedom" "FreedomStrategyDesc" = "Установка стратегии вывода сети в протоколе Freedom"
"RoutingStrategy" = "Настройка маршрутизации доменов" "RoutingStrategy" = "Настройка маршрутизации доменов"
"RoutingStrategyDesc" = "Установка общей стратегии маршрутизации разрешения DNS" "RoutingStrategyDesc" = "Установка общей стратегии маршрутизации разрешения DNS"
"outboundTestUrl" = "URL для теста исходящего"
"outboundTestUrlDesc" = "URL для проверки подключения исходящего"
"Torrent" = "Заблокировать BitTorrent" "Torrent" = "Заблокировать BitTorrent"
"Inbounds" = "Входящие подключения" "Inbounds" = "Входящие подключения"
"InboundsDesc" = "Изменение шаблона конфигурации для подключения определенных клиентов" "InboundsDesc" = "Изменение шаблона конфигурации для подключения определенных клиентов"
@@ -523,6 +525,12 @@
"accountInfo" = "Информация об учетной записи" "accountInfo" = "Информация об учетной записи"
"outboundStatus" = "Статус исходящего подключения" "outboundStatus" = "Статус исходящего подключения"
"sendThrough" = "Отправить через" "sendThrough" = "Отправить через"
"test" = "Тест"
"testResult" = "Результат теста"
"testing" = "Тестирование соединения..."
"testSuccess" = "Тест успешен"
"testFailed" = "Тест не пройден"
"testError" = "Не удалось протестировать исходящее подключение"
[pages.xray.balancer] [pages.xray.balancer]
"addBalancer" = "Создать балансировщик" "addBalancer" = "Создать балансировщик"
@@ -655,6 +663,7 @@
"userSaved" = "✅ Пользователь Telegram сохранен." "userSaved" = "✅ Пользователь Telegram сохранен."
"loginSuccess" = "✅ Успешный вход в панель.\r\n" "loginSuccess" = "✅ Успешный вход в панель.\r\n"
"loginFailed" = "❗️ Ошибка входа в панель.\r\n" "loginFailed" = "❗️ Ошибка входа в панель.\r\n"
"2faFailed" = "Ошибка 2FA"
"report" = "🕰 Запланированные отчеты: {{ .RunTime }}\r\n" "report" = "🕰 Запланированные отчеты: {{ .RunTime }}\r\n"
"datetime" = "⏰ Дата и время: {{ .DateTime }}\r\n" "datetime" = "⏰ Дата и время: {{ .DateTime }}\r\n"
"hostname" = "💻 Имя хоста: {{ .Hostname }}\r\n" "hostname" = "💻 Имя хоста: {{ .Hostname }}\r\n"

View File

@@ -460,6 +460,8 @@
"FreedomStrategyDesc" = "Freedom Protokolünde ağın çıkış stratejisini ayarlayın." "FreedomStrategyDesc" = "Freedom Protokolünde ağın çıkış stratejisini ayarlayın."
"RoutingStrategy" = "Genel Yönlendirme Stratejisi" "RoutingStrategy" = "Genel Yönlendirme Stratejisi"
"RoutingStrategyDesc" = "Tüm istekleri çözmek için genel trafik yönlendirme stratejisini ayarlayın." "RoutingStrategyDesc" = "Tüm istekleri çözmek için genel trafik yönlendirme stratejisini ayarlayın."
"outboundTestUrl" = "Outbound test URL"
"outboundTestUrlDesc" = "Outbound bağlantı testinde kullanılan URL"
"Torrent" = "BitTorrent Protokolünü Engelle" "Torrent" = "BitTorrent Protokolünü Engelle"
"Inbounds" = "Gelenler" "Inbounds" = "Gelenler"
"InboundsDesc" = "Belirli müşterileri kabul eder." "InboundsDesc" = "Belirli müşterileri kabul eder."
@@ -523,6 +525,12 @@
"accountInfo" = "Hesap Bilgileri" "accountInfo" = "Hesap Bilgileri"
"outboundStatus" = "Giden Durumu" "outboundStatus" = "Giden Durumu"
"sendThrough" = "Üzerinden Gönder" "sendThrough" = "Üzerinden Gönder"
"test" = "Test"
"testResult" = "Test Sonucu"
"testing" = "Bağlantı test ediliyor..."
"testSuccess" = "Test başarılı"
"testFailed" = "Test başarısız"
"testError" = "Giden test edilemedi"
[pages.xray.balancer] [pages.xray.balancer]
"addBalancer" = "Dengeleyici Ekle" "addBalancer" = "Dengeleyici Ekle"
@@ -655,6 +663,7 @@
"userSaved" = "✅ Telegram Kullanıcısı kaydedildi." "userSaved" = "✅ Telegram Kullanıcısı kaydedildi."
"loginSuccess" = "✅ Panele başarıyla giriş yapıldı.\r\n" "loginSuccess" = "✅ Panele başarıyla giriş yapıldı.\r\n"
"loginFailed" = "❗Panele giriş denemesi başarısız oldu.\r\n" "loginFailed" = "❗Panele giriş denemesi başarısız oldu.\r\n"
"2faFailed" = "2FA Hatası"
"report" = "🕰 Planlanmış Raporlar: {{ .RunTime }}\r\n" "report" = "🕰 Planlanmış Raporlar: {{ .RunTime }}\r\n"
"datetime" = "⏰ Tarih&Zaman: {{ .DateTime }}\r\n" "datetime" = "⏰ Tarih&Zaman: {{ .DateTime }}\r\n"
"hostname" = "💻 Sunucu: {{ .Hostname }}\r\n" "hostname" = "💻 Sunucu: {{ .Hostname }}\r\n"

View File

@@ -460,6 +460,8 @@
"FreedomStrategyDesc" = "Установити стратегію виведення для мережі в протоколі свободи." "FreedomStrategyDesc" = "Установити стратегію виведення для мережі в протоколі свободи."
"RoutingStrategy" = "Загальна стратегія маршрутизації" "RoutingStrategy" = "Загальна стратегія маршрутизації"
"RoutingStrategyDesc" = "Установити загальну стратегію маршрутизації трафіку для вирішення всіх запитів." "RoutingStrategyDesc" = "Установити загальну стратегію маршрутизації трафіку для вирішення всіх запитів."
"outboundTestUrl" = "URL тесту outbound"
"outboundTestUrlDesc" = "URL для перевірки з'єднання outbound"
"Torrent" = "Блокувати протокол BitTorrent" "Torrent" = "Блокувати протокол BitTorrent"
"Inbounds" = "Вхідні" "Inbounds" = "Вхідні"
"InboundsDesc" = "Прийняття певних клієнтів." "InboundsDesc" = "Прийняття певних клієнтів."
@@ -523,6 +525,12 @@
"accountInfo" = "Інформація про обліковий запис" "accountInfo" = "Інформація про обліковий запис"
"outboundStatus" = "Статус виходу" "outboundStatus" = "Статус виходу"
"sendThrough" = "Надіслати через" "sendThrough" = "Надіслати через"
"test" = "Тест"
"testResult" = "Результат тесту"
"testing" = "Тестування з'єднання..."
"testSuccess" = "Тест успішний"
"testFailed" = "Тест не пройдено"
"testError" = "Не вдалося протестувати вихідне з'єднання"
[pages.xray.balancer] [pages.xray.balancer]
"addBalancer" = "Додати балансир" "addBalancer" = "Додати балансир"
@@ -655,6 +663,7 @@
"userSaved" = "✅ Користувача Telegram збережено." "userSaved" = "✅ Користувача Telegram збережено."
"loginSuccess" = "✅ Успішно ввійшли в панель\r\n" "loginSuccess" = "✅ Успішно ввійшли в панель\r\n"
"loginFailed" = "❗️ Помилка входу в панель.\r\n" "loginFailed" = "❗️ Помилка входу в панель.\r\n"
"2faFailed" = "Помилка 2FA"
"report" = "🕰 Заплановані звіти: {{ .RunTime }}\r\n" "report" = "🕰 Заплановані звіти: {{ .RunTime }}\r\n"
"datetime" = "⏰ Дата й час: {{ .DateTime }}\r\n" "datetime" = "⏰ Дата й час: {{ .DateTime }}\r\n"
"hostname" = "💻 Хост: {{ .Hostname }}\r\n" "hostname" = "💻 Хост: {{ .Hostname }}\r\n"

View File

@@ -460,6 +460,8 @@
"FreedomStrategyDesc" = "Đặt chiến lược đầu ra của mạng trong Giao thức Freedom." "FreedomStrategyDesc" = "Đặt chiến lược đầu ra của mạng trong Giao thức Freedom."
"RoutingStrategy" = "Cấu hình Chiến lược Định tuyến Tên miền" "RoutingStrategy" = "Cấu hình Chiến lược Định tuyến Tên miền"
"RoutingStrategyDesc" = "Đặt chiến lược định tuyến tổng thể cho việc giải quyết DNS." "RoutingStrategyDesc" = "Đặt chiến lược định tuyến tổng thể cho việc giải quyết DNS."
"outboundTestUrl" = "URL kiểm tra outbound"
"outboundTestUrlDesc" = "URL dùng khi kiểm tra kết nối outbound"
"Torrent" = "Cấu hình sử dụng BitTorrent" "Torrent" = "Cấu hình sử dụng BitTorrent"
"Inbounds" = "Đầu vào" "Inbounds" = "Đầu vào"
"InboundsDesc" = "Thay đổi mẫu cấu hình để chấp nhận các máy khách cụ thể." "InboundsDesc" = "Thay đổi mẫu cấu hình để chấp nhận các máy khách cụ thể."
@@ -523,6 +525,12 @@
"accountInfo" = "Thông tin tài khoản" "accountInfo" = "Thông tin tài khoản"
"outboundStatus" = "Trạng thái đầu ra" "outboundStatus" = "Trạng thái đầu ra"
"sendThrough" = "Gửi qua" "sendThrough" = "Gửi qua"
"test" = "Kiểm tra"
"testResult" = "Kết quả kiểm tra"
"testing" = "Đang kiểm tra kết nối..."
"testSuccess" = "Kiểm tra thành công"
"testFailed" = "Kiểm tra thất bại"
"testError" = "Không thể kiểm tra đầu ra"
[pages.xray.balancer] [pages.xray.balancer]
"addBalancer" = "Thêm cân bằng" "addBalancer" = "Thêm cân bằng"
@@ -655,6 +663,7 @@
"userSaved" = "✅ Người dùng Telegram đã được lưu." "userSaved" = "✅ Người dùng Telegram đã được lưu."
"loginSuccess" = "✅ Đăng nhập thành công vào bảng điều khiển.\r\n" "loginSuccess" = "✅ Đăng nhập thành công vào bảng điều khiển.\r\n"
"loginFailed" = "❗️ Đăng nhập vào bảng điều khiển thất bại.\r\n" "loginFailed" = "❗️ Đăng nhập vào bảng điều khiển thất bại.\r\n"
"2faFailed" = "Lỗi 2FA"
"report" = "🕰 Báo cáo định kỳ: {{ .RunTime }}\r\n" "report" = "🕰 Báo cáo định kỳ: {{ .RunTime }}\r\n"
"datetime" = "⏰ Ngày-Giờ: {{ .DateTime }}\r\n" "datetime" = "⏰ Ngày-Giờ: {{ .DateTime }}\r\n"
"hostname" = "💻 Tên máy chủ: {{ .Hostname }}\r\n" "hostname" = "💻 Tên máy chủ: {{ .Hostname }}\r\n"

View File

@@ -460,6 +460,8 @@
"FreedomStrategyDesc" = "设置 Freedom 协议中网络的输出策略" "FreedomStrategyDesc" = "设置 Freedom 协议中网络的输出策略"
"RoutingStrategy" = "配置路由域策略" "RoutingStrategy" = "配置路由域策略"
"RoutingStrategyDesc" = "设置 DNS 解析的整体路由策略" "RoutingStrategyDesc" = "设置 DNS 解析的整体路由策略"
"outboundTestUrl" = "出站测试 URL"
"outboundTestUrlDesc" = "测试出站连接时使用的 URL"
"Torrent" = "屏蔽 BitTorrent 协议" "Torrent" = "屏蔽 BitTorrent 协议"
"Inbounds" = "入站规则" "Inbounds" = "入站规则"
"InboundsDesc" = "接受来自特定客户端的流量" "InboundsDesc" = "接受来自特定客户端的流量"
@@ -523,6 +525,12 @@
"accountInfo" = "帐户信息" "accountInfo" = "帐户信息"
"outboundStatus" = "出站状态" "outboundStatus" = "出站状态"
"sendThrough" = "发送通过" "sendThrough" = "发送通过"
"test" = "测试"
"testResult" = "测试结果"
"testing" = "正在测试连接..."
"testSuccess" = "测试成功"
"testFailed" = "测试失败"
"testError" = "测试出站失败"
[pages.xray.balancer] [pages.xray.balancer]
"addBalancer" = "添加负载均衡" "addBalancer" = "添加负载均衡"
@@ -655,6 +663,7 @@
"userSaved" = "✅ 电报用户已保存。" "userSaved" = "✅ 电报用户已保存。"
"loginSuccess" = "✅ 成功登录到面板。\r\n" "loginSuccess" = "✅ 成功登录到面板。\r\n"
"loginFailed" = "❗️ 面板登录失败。\r\n" "loginFailed" = "❗️ 面板登录失败。\r\n"
"2faFailed" = "2FA 失败"
"report" = "🕰 定时报告:{{ .RunTime }}\r\n" "report" = "🕰 定时报告:{{ .RunTime }}\r\n"
"datetime" = "⏰ 日期时间:{{ .DateTime }}\r\n" "datetime" = "⏰ 日期时间:{{ .DateTime }}\r\n"
"hostname" = "💻 主机名:{{ .Hostname }}\r\n" "hostname" = "💻 主机名:{{ .Hostname }}\r\n"

View File

@@ -460,6 +460,8 @@
"FreedomStrategyDesc" = "設定 Freedom 協議中網路的輸出策略" "FreedomStrategyDesc" = "設定 Freedom 協議中網路的輸出策略"
"RoutingStrategy" = "配置路由域策略" "RoutingStrategy" = "配置路由域策略"
"RoutingStrategyDesc" = "設定 DNS 解析的整體路由策略" "RoutingStrategyDesc" = "設定 DNS 解析的整體路由策略"
"outboundTestUrl" = "出站測試 URL"
"outboundTestUrlDesc" = "測試出站連線時使用的 URL"
"Torrent" = "遮蔽 BitTorrent 協議" "Torrent" = "遮蔽 BitTorrent 協議"
"Inbounds" = "入站規則" "Inbounds" = "入站規則"
"InboundsDesc" = "接受來自特定客戶端的流量" "InboundsDesc" = "接受來自特定客戶端的流量"
@@ -523,6 +525,12 @@
"accountInfo" = "帳戶資訊" "accountInfo" = "帳戶資訊"
"outboundStatus" = "出站狀態" "outboundStatus" = "出站狀態"
"sendThrough" = "傳送通過" "sendThrough" = "傳送通過"
"test" = "測試"
"testResult" = "測試結果"
"testing" = "正在測試連接..."
"testSuccess" = "測試成功"
"testFailed" = "測試失敗"
"testError" = "測試出站失敗"
[pages.xray.balancer] [pages.xray.balancer]
"addBalancer" = "新增負載均衡" "addBalancer" = "新增負載均衡"
@@ -655,6 +663,7 @@
"userSaved" = "✅ 電報使用者已儲存。" "userSaved" = "✅ 電報使用者已儲存。"
"loginSuccess" = "✅ 成功登入到面板。\r\n" "loginSuccess" = "✅ 成功登入到面板。\r\n"
"loginFailed" = "❗️ 面板登入失敗。\r\n" "loginFailed" = "❗️ 面板登入失敗。\r\n"
"2faFailed" = "2FA 失敗"
"report" = "🕰 定時報告:{{ .RunTime }}\r\n" "report" = "🕰 定時報告:{{ .RunTime }}\r\n"
"datetime" = "⏰ 日期時間:{{ .DateTime }}\r\n" "datetime" = "⏰ 日期時間:{{ .DateTime }}\r\n"
"hostname" = "💻 主機名:{{ .Hostname }}\r\n" "hostname" = "💻 主機名:{{ .Hostname }}\r\n"

View File

@@ -200,7 +200,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
engine.Use(gzip.Gzip(gzip.DefaultCompression, gzip.WithExcludedPaths([]string{basePath + "panel/api/"}))) engine.Use(gzip.Gzip(gzip.DefaultCompression))
assetsBasePath := basePath + "assets/" assetsBasePath := basePath + "assets/"
store := cookie.NewStore(secret) store := cookie.NewStore(secret)
@@ -490,3 +490,7 @@ func (s *Server) GetCron() *cron.Cron {
func (s *Server) GetWSHub() any { func (s *Server) GetWSHub() any {
return s.wsHub return s.wsHub
} }
func (s *Server) RestartXray() error {
return s.xrayService.RestartXray(true)
}

View File

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

View File

@@ -10,4 +10,9 @@ depend() {
} }
start_pre(){ start_pre(){
cd /usr/local/x-ui cd /usr/local/x-ui
}
reload() {
ebegin "Reloading ${RC_SVCNAME}"
kill -USR1 $pidfile
eend $?
} }

View File

@@ -9,6 +9,7 @@ Environment="XRAY_VMESS_AEAD_FORCED=false"
Type=simple Type=simple
WorkingDirectory=/usr/lib/x-ui/ WorkingDirectory=/usr/lib/x-ui/
ExecStart=/usr/lib/x-ui/x-ui ExecStart=/usr/lib/x-ui/x-ui
ExecReload=kill -USR1 $MAINPID
Restart=on-failure Restart=on-failure
RestartSec=5s RestartSec=5s

View File

@@ -9,6 +9,7 @@ Environment="XRAY_VMESS_AEAD_FORCED=false"
Type=simple Type=simple
WorkingDirectory=/usr/local/x-ui/ WorkingDirectory=/usr/local/x-ui/
ExecStart=/usr/local/x-ui/x-ui ExecStart=/usr/local/x-ui/x-ui
ExecReload=kill -USR1 $MAINPID
Restart=on-failure Restart=on-failure
RestartSec=5s RestartSec=5s

View File

@@ -9,6 +9,7 @@ Environment="XRAY_VMESS_AEAD_FORCED=false"
Type=simple Type=simple
WorkingDirectory=/usr/local/x-ui/ WorkingDirectory=/usr/local/x-ui/
ExecStart=/usr/local/x-ui/x-ui ExecStart=/usr/local/x-ui/x-ui
ExecReload=kill -USR1 $MAINPID
Restart=on-failure Restart=on-failure
RestartSec=5s RestartSec=5s

84
x-ui.sh
View File

@@ -317,12 +317,12 @@ check_config() {
start >/dev/null 2>&1 start >/dev/null 2>&1
else else
LOGE "IP certificate setup failed." LOGE "IP certificate setup failed."
echo -e "${yellow}You can try again via option 18 (SSL Certificate Management).${plain}" echo -e "${yellow}You can try again via option 19 (SSL Certificate Management).${plain}"
start >/dev/null 2>&1 start >/dev/null 2>&1
fi fi
else else
echo -e "${yellow}Access URL: http://${server_ip}:${existing_port}${existing_webBasePath}${plain}" echo -e "${yellow}Access URL: http://${server_ip}:${existing_port}${existing_webBasePath}${plain}"
echo -e "${yellow}For security, please configure SSL certificate using option 18 (SSL Certificate Management)${plain}" echo -e "${yellow}For security, please configure SSL certificate using option 19 (SSL Certificate Management)${plain}"
fi fi
fi fi
} }
@@ -408,6 +408,16 @@ restart() {
fi fi
} }
restart_xray() {
systemctl reload x-ui
LOGI "xray-core Restart signal sent successfully, Please check the log information to confirm whether xray restarted successfully"
sleep 2
show_xray_status
if [[ $# == 0 ]]; then
before_show_menu
fi
}
status() { status() {
if [[ $release == "alpine" ]]; then if [[ $release == "alpine" ]]; then
rc-service x-ui status rc-service x-ui status
@@ -421,7 +431,7 @@ status() {
enable() { enable() {
if [[ $release == "alpine" ]]; then if [[ $release == "alpine" ]]; then
rc-update add x-ui rc-update add x-ui default
else else
systemctl enable x-ui systemctl enable x-ui
fi fi
@@ -2002,7 +2012,7 @@ EOF
cat << EOF > /etc/fail2ban/filter.d/3x-ipl.conf cat << EOF > /etc/fail2ban/filter.d/3x-ipl.conf
[Definition] [Definition]
datepattern = ^%%Y/%%m/%%d %%H:%%M:%%S datepattern = ^%%Y/%%m/%%d %%H:%%M:%%S
failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*SRC\s*=\s*<ADDR> failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnecting OLD IP\s*=\s*<ADDR>\s*\|\|\s*Timestamp\s*=\s*\d+
ignoreregex = ignoreregex =
EOF EOF
@@ -2062,11 +2072,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}')
@@ -2150,6 +2164,7 @@ show_usage() {
${blue}x-ui start${plain} - Start │ ${blue}x-ui start${plain} - Start │
${blue}x-ui stop${plain} - Stop │ ${blue}x-ui stop${plain} - Stop │
${blue}x-ui restart${plain} - Restart │ ${blue}x-ui restart${plain} - Restart │
| ${blue}x-ui restart-xray${plain} - Restart Xray │
${blue}x-ui status${plain} - Current Status │ ${blue}x-ui status${plain} - Current Status │
${blue}x-ui settings${plain} - Current Settings │ ${blue}x-ui settings${plain} - Current Settings │
${blue}x-ui enable${plain} - Enable Autostart on OS Startup │ ${blue}x-ui enable${plain} - Enable Autostart on OS Startup │
@@ -2185,25 +2200,26 @@ show_menu() {
${green}11.${plain} Start │ ${green}11.${plain} Start │
${green}12.${plain} Stop │ ${green}12.${plain} Stop │
${green}13.${plain} Restart │ ${green}13.${plain} Restart │
${green}14.${plain} Check Status | ${green}14.${plain} Restart Xray
${green}15.${plain} Logs Management ${green}15.${plain} Check Status
${green}16.${plain} Logs Management │
│────────────────────────────────────────────────│ │────────────────────────────────────────────────│
${green}16.${plain} Enable Autostart │ ${green}17.${plain} Enable Autostart │
${green}17.${plain} Disable Autostart │ ${green}18.${plain} Disable Autostart │
│────────────────────────────────────────────────│ │────────────────────────────────────────────────│
${green}18.${plain} SSL Certificate Management │ ${green}19.${plain} SSL Certificate Management │
${green}19.${plain} Cloudflare SSL Certificate │ ${green}20.${plain} Cloudflare SSL Certificate │
${green}20.${plain} IP Limit Management │ ${green}21.${plain} IP Limit Management │
${green}21.${plain} Firewall Management │ ${green}22.${plain} Firewall Management │
${green}22.${plain} SSH Port Forwarding Management │ ${green}23.${plain} SSH Port Forwarding Management │
│────────────────────────────────────────────────│ │────────────────────────────────────────────────│
${green}23.${plain} Enable BBR │ ${green}24.${plain} Enable BBR │
${green}24.${plain} Update Geo Files │ ${green}25.${plain} Update Geo Files │
${green}25.${plain} Speedtest by Ookla │ ${green}26.${plain} Speedtest by Ookla │
╚────────────────────────────────────────────────╝ ╚────────────────────────────────────────────────╝
" "
show_status show_status
echo && read -rp "Please enter your selection [0-25]: " num echo && read -rp "Please enter your selection [0-26]: " num
case "${num}" in case "${num}" in
0) 0)
@@ -2249,43 +2265,46 @@ show_menu() {
check_install && restart check_install && restart
;; ;;
14) 14)
check_install && status check_install && restart_xray
;; ;;
15) 15)
check_install && show_log check_install && status
;; ;;
16) 16)
check_install && enable check_install && show_log
;; ;;
17) 17)
check_install && disable check_install && enable
;; ;;
18) 18)
ssl_cert_issue_main check_install && disable
;; ;;
19) 19)
ssl_cert_issue_CF ssl_cert_issue_main
;; ;;
20) 20)
iplimit_main ssl_cert_issue_CF
;; ;;
21) 21)
firewall_menu iplimit_main
;; ;;
22) 22)
SSH_port_forwarding firewall_menu
;; ;;
23) 23)
bbr_menu SSH_port_forwarding
;; ;;
24) 24)
update_geo bbr_menu
;; ;;
25) 25)
update_geo
;;
26)
run_speedtest run_speedtest
;; ;;
*) *)
LOGE "Please enter the correct number [0-25]" LOGE "Please enter the correct number [0-26]"
;; ;;
esac esac
} }
@@ -2301,6 +2320,9 @@ if [[ $# > 0 ]]; then
"restart") "restart")
check_install 0 && restart 0 check_install 0 && restart 0
;; ;;
"restart-xray")
check_install 0 && restart_xray 0
;;
"status") "status")
check_install 0 && status 0 check_install 0 && status 0
;; ;;

View File

@@ -110,6 +110,15 @@ func NewProcess(xrayConfig *Config) *Process {
return p return p
} }
// NewTestProcess creates a new Xray process that uses a specific config file path.
// Used for test runs (e.g. outbound test) so the main config.json is not overwritten.
// The config file at configPath is removed when the process is stopped.
func NewTestProcess(xrayConfig *Config, configPath string) *Process {
p := &Process{newTestProcess(xrayConfig, configPath)}
runtime.SetFinalizer(p, stopProcess)
return p
}
type process struct { type process struct {
cmd *exec.Cmd cmd *exec.Cmd
@@ -118,10 +127,11 @@ type process struct {
onlineClients []string onlineClients []string
config *Config config *Config
logWriter *LogWriter configPath string // if set, use this path instead of GetConfigPath() and remove on Stop
exitErr error logWriter *LogWriter
startTime time.Time exitErr error
startTime time.Time
} }
// newProcess creates a new internal process struct for Xray. // newProcess creates a new internal process struct for Xray.
@@ -134,6 +144,13 @@ func newProcess(config *Config) *process {
} }
} }
// newTestProcess creates a process that writes and runs with a specific config path.
func newTestProcess(config *Config, configPath string) *process {
p := newProcess(config)
p.configPath = configPath
return p
}
// IsRunning returns true if the Xray process is currently running. // IsRunning returns true if the Xray process is currently running.
func (p *process) IsRunning() bool { func (p *process) IsRunning() bool {
if p.cmd == nil || p.cmd.Process == nil { if p.cmd == nil || p.cmd.Process == nil {
@@ -238,6 +255,9 @@ func (p *process) Start() (err error) {
} }
configPath := GetConfigPath() configPath := GetConfigPath()
if p.configPath != "" {
configPath = p.configPath
}
err = os.WriteFile(configPath, data, fs.ModePerm) err = os.WriteFile(configPath, data, fs.ModePerm)
if err != nil { if err != nil {
return common.NewErrorf("Failed to write configuration file: %v", err) return common.NewErrorf("Failed to write configuration file: %v", err)
@@ -278,6 +298,16 @@ func (p *process) Stop() error {
return errors.New("xray is not running") return errors.New("xray is not running")
} }
// Remove temporary config file used for test runs so main config is never touched
if p.configPath != "" {
if p.configPath != GetConfigPath() {
// Check if file exists before removing
if _, err := os.Stat(p.configPath); err == nil {
_ = os.Remove(p.configPath)
}
}
}
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
return p.cmd.Process.Kill() return p.cmd.Process.Kill()
} else { } else {