mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 20:54:14 +00:00
fix(sub): isolate per-proxy tlsSettings during external-proxy iteration
cloneMap (Clash) is shallow and `newStream := stream` (JSON) is an alias, so tlsSettings was shared across iterations. The new applyExternalProxyTLSToStream mutates it, leaking one proxy's serverName/fingerprint/alpn into the next (only overwritten when the next proxy explicitly sets the same field). Add cloneStreamForExternalProxy: shallow clones the top-level stream plus deep clones tlsSettings and tlsSettings.settings. Regression test locks in that proxy B does not inherit proxy A's fingerprint/alpn when B leaves them unset.
This commit is contained in:
parent
b428399f3a
commit
aa849adb90
6 changed files with 57 additions and 161 deletions
158
.github/copilot-instructions.md
vendored
158
.github/copilot-instructions.md
vendored
|
|
@ -1,158 +0,0 @@
|
||||||
# 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/<lang>.json` (one nested-namespace file per locale,
|
|
||||||
e.g. `en-US.json`). Vue SPA imports these via `import.meta.glob` from `frontend/src/i18n/`,
|
|
||||||
and the Go binary embeds the same files via `web/web.go`'s `//go:embed translation/*`.
|
|
||||||
- Access from Go via `locale.I18n(locale.Web, "pages.login.loginAgain")` (see
|
|
||||||
`web/locale/locale.go`); access from Vue via `useI18n()` and `t('pages.login.loginAgain')`.
|
|
||||||
- Use `locale.I18nType` enum (Web, Bot).
|
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>3X-UI</title>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="message"></div>
|
<div id="message"></div>
|
||||||
|
|
|
||||||
|
|
@ -139,7 +139,7 @@ func (s *SubClashService) getProxies(inbound *model.Inbound, client model.Client
|
||||||
workingInbound := *inbound
|
workingInbound := *inbound
|
||||||
workingInbound.Listen = extPrxy["dest"].(string)
|
workingInbound.Listen = extPrxy["dest"].(string)
|
||||||
workingInbound.Port = int(extPrxy["port"].(float64))
|
workingInbound.Port = int(extPrxy["port"].(float64))
|
||||||
workingStream := cloneMap(stream)
|
workingStream := cloneStreamForExternalProxy(stream)
|
||||||
|
|
||||||
switch extPrxy["forceTls"].(string) {
|
switch extPrxy["forceTls"].(string) {
|
||||||
case "tls":
|
case "tls":
|
||||||
|
|
|
||||||
|
|
@ -192,7 +192,7 @@ func (s *SubJsonService) getConfig(inbound *model.Inbound, client model.Client,
|
||||||
extPrxy := ep.(map[string]any)
|
extPrxy := ep.(map[string]any)
|
||||||
inbound.Listen = extPrxy["dest"].(string)
|
inbound.Listen = extPrxy["dest"].(string)
|
||||||
inbound.Port = int(extPrxy["port"].(float64))
|
inbound.Port = int(extPrxy["port"].(float64))
|
||||||
newStream := stream
|
newStream := cloneStreamForExternalProxy(stream)
|
||||||
switch extPrxy["forceTls"].(string) {
|
switch extPrxy["forceTls"].(string) {
|
||||||
case "tls":
|
case "tls":
|
||||||
if newStream["security"] != "tls" {
|
if newStream["security"] != "tls" {
|
||||||
|
|
|
||||||
|
|
@ -879,6 +879,24 @@ func applyExternalProxyTLSParams(ep map[string]any, params map[string]string, se
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cloneStreamForExternalProxy returns a shallow clone of stream with
|
||||||
|
// tlsSettings (and its nested settings map) deep-copied. The external
|
||||||
|
// proxy loop mutates tlsSettings per iteration, so without isolating
|
||||||
|
// those maps each proxy's SNI/fingerprint/ALPN would leak into the next.
|
||||||
|
func cloneStreamForExternalProxy(stream map[string]any) map[string]any {
|
||||||
|
out := cloneMap(stream)
|
||||||
|
ts, ok := out["tlsSettings"].(map[string]any)
|
||||||
|
if !ok || ts == nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
clonedTs := cloneMap(ts)
|
||||||
|
if inner, ok := clonedTs["settings"].(map[string]any); ok && inner != nil {
|
||||||
|
clonedTs["settings"] = cloneMap(inner)
|
||||||
|
}
|
||||||
|
out["tlsSettings"] = clonedTs
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func applyExternalProxyTLSToStream(ep map[string]any, stream map[string]any, security string) {
|
func applyExternalProxyTLSToStream(ep map[string]any, stream map[string]any, security string) {
|
||||||
if security != "tls" {
|
if security != "tls" {
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -478,6 +478,43 @@ func TestApplyExternalProxyTLSParams_FallsBackToDestSNI(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApplyExternalProxyTLSToStream_DoesNotLeakAcrossProxies(t *testing.T) {
|
||||||
|
stream := map[string]any{
|
||||||
|
"security": "tls",
|
||||||
|
"tlsSettings": map[string]any{},
|
||||||
|
}
|
||||||
|
proxies := []map[string]any{
|
||||||
|
{"dest": "a.example.com", "fingerprint": "chrome", "alpn": []any{"h3"}},
|
||||||
|
{"dest": "b.example.com"},
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]map[string]any, 0, len(proxies))
|
||||||
|
for _, ep := range proxies {
|
||||||
|
working := cloneStreamForExternalProxy(stream)
|
||||||
|
applyExternalProxyTLSToStream(ep, working, "tls")
|
||||||
|
ts := working["tlsSettings"].(map[string]any)
|
||||||
|
snapshot := map[string]any{
|
||||||
|
"serverName": ts["serverName"],
|
||||||
|
"fingerprint": ts["fingerprint"],
|
||||||
|
"alpn": ts["alpn"],
|
||||||
|
}
|
||||||
|
results = append(results, snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
if results[0]["serverName"] != "a.example.com" || results[0]["fingerprint"] != "chrome" {
|
||||||
|
t.Fatalf("proxy A snapshot = %v", results[0])
|
||||||
|
}
|
||||||
|
if results[1]["serverName"] != "b.example.com" {
|
||||||
|
t.Fatalf("proxy B serverName = %v, want b.example.com", results[1]["serverName"])
|
||||||
|
}
|
||||||
|
if results[1]["fingerprint"] != nil {
|
||||||
|
t.Fatalf("proxy B should inherit no fingerprint, got %v (leaked from A)", results[1]["fingerprint"])
|
||||||
|
}
|
||||||
|
if results[1]["alpn"] != nil {
|
||||||
|
t.Fatalf("proxy B should inherit no alpn, got %v (leaked from A)", results[1]["alpn"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestApplyExternalProxyTLSParams_DoesNotApplyForNone(t *testing.T) {
|
func TestApplyExternalProxyTLSParams_DoesNotApplyForNone(t *testing.T) {
|
||||||
params := map[string]string{
|
params := map[string]string{
|
||||||
"security": "none",
|
"security": "none",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue