From 1f90d2a6eeee27693bcc7fd8453c5ed7647ed51d Mon Sep 17 00:00:00 2001 From: Maksim Alekseev <31767561+beehunt9r@users.noreply.github.com> Date: Sun, 24 May 2026 22:54:26 +0300 Subject: [PATCH] feat(inbound): Advanced XHTTP and external TLS proxy settings (#4491) * :sparkles: Introduce extended XHTTP and external proxy settings * :sparkles: Add custom SNI for proxy * :sparkles: Add previous changes into React version of app * 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. --- .github/copilot-instructions.md | 158 ------------- frontend/index.html | 1 - frontend/src/models/inbound.js | 95 +++++--- frontend/src/models/outbound.js | 34 ++- .../src/pages/inbounds/InboundFormModal.tsx | 72 ++++-- sub/subClashService.go | 20 +- sub/subJsonService.go | 9 +- sub/subService.go | 218 +++++++++++++++++- sub/subService_test.go | 170 ++++++++++++++ 9 files changed, 553 insertions(+), 224 deletions(-) delete mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index 225e2cf7..00000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -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/.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 diff --git a/frontend/index.html b/frontend/index.html index 3ec1b86a..3da3b650 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,7 +3,6 @@ - 3X-UI
diff --git a/frontend/src/models/inbound.js b/frontend/src/models/inbound.js index f333c628..d98dd8ba 100644 --- a/frontend/src/models/inbound.js +++ b/frontend/src/models/inbound.js @@ -499,14 +499,13 @@ export class HTTPUpgradeStreamSettings extends XrayCommonClass { // Mirrors the inbound (server-side) view of Xray-core's SplitHTTPConfig // (infra/conf/transport_internet.go). Only fields the server actually // reads at runtime, plus the bidirectional fields the server enforces, -// live here. Client-only fields (uplinkHTTPMethod, uplinkChunkSize, -// noGRPCHeader, scMinPostsIntervalMs, xmux, downloadSettings) belong on -// the outbound class instead. +// live here. Most client-only fields (uplinkChunkSize, noGRPCHeader, +// scMinPostsIntervalMs, xmux, downloadSettings) belong on the outbound +// class instead. // -// `headers` is technically client-only at runtime (xray's listener -// doesn't read it) but we keep it here so the admin can set request -// headers that get embedded into the share link's `extra` blob — the -// client picks them up from there. +// `headers` and `uplinkHTTPMethod` are client-only at runtime (xray's +// listener doesn't read them) but we keep them here so the admin can set +// values that get embedded into the share link's `extra` blob. export class xHTTPStreamSettings extends XrayCommonClass { constructor( // Bidirectional — must match between client and server @@ -533,6 +532,7 @@ export class xHTTPStreamSettings extends XrayCommonClass { serverMaxHeaderBytes = 0, // URL-share only — embedded in the link's `extra` blob so clients // pick them up; xray's listener ignores them at runtime. + uplinkHTTPMethod = '', headers = [], ) { super(); @@ -556,6 +556,7 @@ export class xHTTPStreamSettings extends XrayCommonClass { this.scMaxBufferedPosts = scMaxBufferedPosts; this.scStreamUpServerSecs = scStreamUpServerSecs; this.serverMaxHeaderBytes = serverMaxHeaderBytes; + this.uplinkHTTPMethod = uplinkHTTPMethod; this.headers = headers; } @@ -589,6 +590,7 @@ export class xHTTPStreamSettings extends XrayCommonClass { json.scMaxBufferedPosts, json.scStreamUpServerSecs, json.serverMaxHeaderBytes, + json.uplinkHTTPMethod, XrayCommonClass.toHeaders(json.headers), ); } @@ -615,6 +617,7 @@ export class xHTTPStreamSettings extends XrayCommonClass { scMaxBufferedPosts: this.scMaxBufferedPosts, scStreamUpServerSecs: this.scStreamUpServerSecs, serverMaxHeaderBytes: this.serverMaxHeaderBytes, + uplinkHTTPMethod: this.uplinkHTTPMethod, headers: XrayCommonClass.toV2Headers(this.headers, false), }; } @@ -1584,10 +1587,9 @@ export class Inbound extends XrayCommonClass { // - server-only (noSSEHeader, scMaxBufferedPosts, // scStreamUpServerSecs, serverMaxHeaderBytes) — client wouldn't // read them, so emitting them just bloats the URL. - // - client-only (headers, uplinkHTTPMethod, uplinkChunkSize, - // noGRPCHeader, scMinPostsIntervalMs, xmux, downloadSettings) — - // not on the inbound class at all; the client configures them - // locally. + // - client-only values are included only when present on the inbound + // object. Imported/API-created configs can carry them there, and + // the share link is the only place clients can receive them. // // Truthy-only guards keep default inbounds emitting the same compact // URL they did before this helper grew. @@ -1607,21 +1609,35 @@ export class Inbound extends XrayCommonClass { }); } - if (typeof xhttp.mode === 'string' && xhttp.mode.length > 0) { - extra.mode = xhttp.mode; - } - const stringFields = [ + "uplinkHTTPMethod", "sessionPlacement", "sessionKey", "seqPlacement", "seqKey", "uplinkDataPlacement", "uplinkDataKey", - "scMaxEachPostBytes", + "scMaxEachPostBytes", "scMinPostsIntervalMs", ]; for (const k of stringFields) { const v = xhttp[k]; if (typeof v === 'string' && v.length > 0) extra[k] = v; } + const uplinkChunkSize = xhttp.uplinkChunkSize; + if ((typeof uplinkChunkSize === 'number' && uplinkChunkSize !== 0) || + (typeof uplinkChunkSize === 'string' && uplinkChunkSize.length > 0)) { + extra.uplinkChunkSize = uplinkChunkSize; + } + + if (xhttp.noGRPCHeader === true) { + extra.noGRPCHeader = true; + } + + for (const k of ["xmux", "downloadSettings"]) { + const v = xhttp[k]; + if (v && typeof v === 'object' && Object.keys(v).length > 0) { + extra[k] = v; + } + } + // Headers — emitted as the {name: value} map upstream's struct // expects. The server runtime ignores this field, but the client // (consuming the share link) honors it. @@ -1680,6 +1696,29 @@ export class Inbound extends XrayCommonClass { } } + static externalProxyAlpn(value) { + if (Array.isArray(value)) return value.filter(Boolean).join(','); + return typeof value === 'string' ? value : ''; + } + + static applyExternalProxyTLSParams(externalProxy, params, security) { + if (!externalProxy || security !== 'tls') return; + const sni = externalProxy.sni?.length > 0 ? externalProxy.sni : externalProxy.dest; + if (sni?.length > 0) params.set("sni", sni); + if (externalProxy.fingerprint?.length > 0) params.set("fp", externalProxy.fingerprint); + const alpn = Inbound.externalProxyAlpn(externalProxy.alpn); + if (alpn.length > 0) params.set("alpn", alpn); + } + + static applyExternalProxyTLSObj(externalProxy, obj, security) { + if (!externalProxy || !obj || security !== 'tls') return; + const sni = externalProxy.sni?.length > 0 ? externalProxy.sni : externalProxy.dest; + if (sni?.length > 0) obj.sni = sni; + if (externalProxy.fingerprint?.length > 0) obj.fp = externalProxy.fingerprint; + const alpn = Inbound.externalProxyAlpn(externalProxy.alpn); + if (alpn.length > 0) obj.alpn = alpn; + } + static hasShareableFinalMaskValue(value) { if (value == null) { return false; @@ -1894,7 +1933,7 @@ export class Inbound extends XrayCommonClass { this.sniffing = new Sniffing(); } - genVmessLink(address = '', port = this.port, forceTls, remark = '', clientId, security) { + genVmessLink(address = '', port = this.port, forceTls, remark = '', clientId, security, externalProxy = null) { if (this.protocol !== Protocols.VMESS) { return ''; } @@ -1958,11 +1997,12 @@ export class Inbound extends XrayCommonClass { obj.alpn = this.stream.tls.alpn.join(','); } } + Inbound.applyExternalProxyTLSObj(externalProxy, obj, tls); return 'vmess://' + Base64.encode(JSON.stringify(obj, null, 2)); } - genVLESSLink(address = '', port = this.port, forceTls, remark = '', clientId, flow) { + genVLESSLink(address = '', port = this.port, forceTls, remark = '', clientId, flow, externalProxy = null) { const uuid = clientId; const type = this.stream.network; const security = forceTls == 'same' ? this.stream.security : forceTls; @@ -2028,6 +2068,7 @@ export class Inbound extends XrayCommonClass { params.set("flow", flow); } } + Inbound.applyExternalProxyTLSParams(externalProxy, params, security); } else if (security === 'reality') { @@ -2064,7 +2105,7 @@ export class Inbound extends XrayCommonClass { return url.toString(); } - genSSLink(address = '', port = this.port, forceTls, remark = '', clientPassword) { + genSSLink(address = '', port = this.port, forceTls, remark = '', clientPassword, externalProxy = null) { let settings = this.settings; const type = this.stream.network; const security = forceTls == 'same' ? this.stream.security : forceTls; @@ -2126,6 +2167,7 @@ export class Inbound extends XrayCommonClass { params.set("sni", this.stream.tls.sni); } } + Inbound.applyExternalProxyTLSParams(externalProxy, params, security); } @@ -2142,7 +2184,7 @@ export class Inbound extends XrayCommonClass { return url.toString(); } - genTrojanLink(address = '', port = this.port, forceTls, remark = '', clientPassword) { + genTrojanLink(address = '', port = this.port, forceTls, remark = '', clientPassword, externalProxy = null) { const security = forceTls == 'same' ? this.stream.security : forceTls; const type = this.stream.network; const params = new Map(); @@ -2203,6 +2245,7 @@ export class Inbound extends XrayCommonClass { params.set("sni", this.stream.tls.sni); } } + Inbound.applyExternalProxyTLSParams(externalProxy, params, security); } else if (security === 'reality') { @@ -2344,16 +2387,16 @@ export class Inbound extends XrayCommonClass { return links.join('\r\n'); } - genLink(address = '', port = this.port, forceTls = 'same', remark = '', client) { + genLink(address = '', port = this.port, forceTls = 'same', remark = '', client, externalProxy = null) { switch (this.protocol) { case Protocols.VMESS: - return this.genVmessLink(address, port, forceTls, remark, client.id, client.security); + return this.genVmessLink(address, port, forceTls, remark, client.id, client.security, externalProxy); case Protocols.VLESS: - return this.genVLESSLink(address, port, forceTls, remark, client.id, client.flow); + return this.genVLESSLink(address, port, forceTls, remark, client.id, client.flow, externalProxy); case Protocols.SHADOWSOCKS: - return this.genSSLink(address, port, forceTls, remark, this.isSSMultiUser ? client.password : ''); + return this.genSSLink(address, port, forceTls, remark, this.isSSMultiUser ? client.password : '', externalProxy); case Protocols.TROJAN: - return this.genTrojanLink(address, port, forceTls, remark, client.password); + return this.genTrojanLink(address, port, forceTls, remark, client.password, externalProxy); case Protocols.HYSTERIA: return this.genHysteriaLink(address, port, remark, client.auth.length > 0 ? client.auth : this.stream.hysteria.auth); default: return ''; @@ -2384,7 +2427,7 @@ export class Inbound extends XrayCommonClass { let r = orderChars.split('').map(char => orders[char]).filter(x => x.length > 0).join(separationChar); result.push({ remark: r, - link: this.genLink(ep.dest, ep.port, ep.forceTls, r, client) + link: this.genLink(ep.dest, ep.port, ep.forceTls, r, client, ep) }); }); } diff --git a/frontend/src/models/outbound.js b/frontend/src/models/outbound.js index c696bc07..79af2682 100644 --- a/frontend/src/models/outbound.js +++ b/frontend/src/models/outbound.js @@ -1407,10 +1407,24 @@ export class Outbound extends CommonClass { }); } // Bidirectional string fields carried in the extra block - const xFields = ["sessionPlacement", "sessionKey", "seqPlacement", "seqKey", "uplinkDataPlacement", "uplinkDataKey", "scMaxEachPostBytes"]; + const xFields = [ + "uplinkHTTPMethod", + "sessionPlacement", "sessionKey", + "seqPlacement", "seqKey", + "uplinkDataPlacement", "uplinkDataKey", + "scMaxEachPostBytes", "scMinPostsIntervalMs", + ]; xFields.forEach(k => { if (typeof json[k] === 'string' && json[k]) xh[k] = json[k]; }); + if (typeof json.uplinkChunkSize === 'number' && json.uplinkChunkSize !== 0) xh.uplinkChunkSize = json.uplinkChunkSize; + if (typeof json.uplinkChunkSize === 'string' && json.uplinkChunkSize) xh.uplinkChunkSize = json.uplinkChunkSize; + if (json.noGRPCHeader === true) xh.noGRPCHeader = true; + if (json.xmux && typeof json.xmux === 'object') { + xh.xmux = json.xmux; + xh.enableXmux = true; + } + if (json.downloadSettings && typeof json.downloadSettings === 'object') xh.downloadSettings = json.downloadSettings; // Headers — VMess extra emits them as a {name: value} map if (json.headers && typeof json.headers === 'object' && !Array.isArray(json.headers)) { xh.headers = Object.entries(json.headers).map(([name, value]) => ({ name, value })); @@ -1487,10 +1501,24 @@ export class Outbound extends CommonClass { }); if (!xh.mode && typeof extra.mode === 'string' && extra.mode) xh.mode = extra.mode; // Bidirectional string fields carried inside the extra block - const xFields = ["sessionPlacement", "sessionKey", "seqPlacement", "seqKey", "uplinkDataPlacement", "uplinkDataKey", "scMaxEachPostBytes"]; + const xFields = [ + "uplinkHTTPMethod", + "sessionPlacement", "sessionKey", + "seqPlacement", "seqKey", + "uplinkDataPlacement", "uplinkDataKey", + "scMaxEachPostBytes", "scMinPostsIntervalMs", + ]; xFields.forEach(k => { if (typeof extra[k] === 'string' && extra[k]) xh[k] = extra[k]; }); + if (typeof extra.uplinkChunkSize === 'number' && extra.uplinkChunkSize !== 0) xh.uplinkChunkSize = extra.uplinkChunkSize; + if (typeof extra.uplinkChunkSize === 'string' && extra.uplinkChunkSize) xh.uplinkChunkSize = extra.uplinkChunkSize; + if (extra.noGRPCHeader === true) xh.noGRPCHeader = true; + if (extra.xmux && typeof extra.xmux === 'object') { + xh.xmux = extra.xmux; + xh.enableXmux = true; + } + if (extra.downloadSettings && typeof extra.downloadSettings === 'object') xh.downloadSettings = extra.downloadSettings; // Headers — extra emits them as a {name: value} map if (extra.headers && typeof extra.headers === 'object' && !Array.isArray(extra.headers)) { xh.headers = Object.entries(extra.headers).map(([name, value]) => ({ name, value })); @@ -2354,4 +2382,4 @@ Outbound.HysteriaSettings = class extends CommonClass { version: this.version }; } -}; \ No newline at end of file +}; diff --git a/frontend/src/pages/inbounds/InboundFormModal.tsx b/frontend/src/pages/inbounds/InboundFormModal.tsx index 1e0a9b39..369a1945 100644 --- a/frontend/src/pages/inbounds/InboundFormModal.tsx +++ b/frontend/src/pages/inbounds/InboundFormModal.tsx @@ -319,6 +319,9 @@ export default function InboundFormModal({ dest: window.location.hostname, port: ib.port, remark: '', + sni: '', + fingerprint: '', + alpn: [], }]; } else { ib.stream.externalProxy = []; @@ -1617,6 +1620,14 @@ export default function InboundFormModal({ )} { ib.stream.xhttp.serverMaxHeaderBytes = Number(v) || 0; refresh(); }} /> { ib.stream.xhttp.xPaddingBytes = e.target.value; refresh(); }} /> + + + { ib.stream.xhttp.xPaddingObfsMode = v; refresh(); }} /> {ib.stream.xhttp.xPaddingObfsMode && ( <> @@ -1686,34 +1697,51 @@ export default function InboundFormModal({ {externalProxyOn && ( )} {externalProxyOn && ( - {(ib.stream.externalProxy as { forceTls: string; dest: string; port: number; remark: string }[]).map((row, idx) => ( - - - - - { row.dest = e.target.value; refresh(); }} /> - - { row.port = Number(v) || 0; refresh(); }} /> - - { row.remark = e.target.value; refresh(); }} /> - { ib.stream.externalProxy.splice(idx, 1); refresh(); }}> - - - + {(ib.stream.externalProxy as { forceTls: string; dest: string; port: number; remark: string; sni?: string; fingerprint?: string; alpn?: string[] }[]).map((row, idx) => ( +
+ + + + + { row.dest = e.target.value; refresh(); }} /> + + { row.port = Number(v) || 0; refresh(); }} /> + + { row.remark = e.target.value; refresh(); }} /> + { ib.stream.externalProxy.splice(idx, 1); refresh(); }}> + + + + {row.forceTls === 'tls' && ( + + { row.sni = e.target.value; refresh(); }} /> + + + + )} +
))}
)} diff --git a/sub/subClashService.go b/sub/subClashService.go index 7b638dfe..68f0c3bc 100644 --- a/sub/subClashService.go +++ b/sub/subClashService.go @@ -122,7 +122,8 @@ func (s *SubClashService) getProxies(inbound *model.Inbound, client model.Client defaultDest = host } externalProxies, ok := stream["externalProxy"].([]any) - if !ok || len(externalProxies) == 0 { + hasExternalProxy := ok && len(externalProxies) > 0 + if !hasExternalProxy { externalProxies = []any{map[string]any{ "forceTls": "same", "dest": defaultDest, @@ -138,7 +139,7 @@ func (s *SubClashService) getProxies(inbound *model.Inbound, client model.Client workingInbound := *inbound workingInbound.Listen = extPrxy["dest"].(string) workingInbound.Port = int(extPrxy["port"].(float64)) - workingStream := cloneMap(stream) + workingStream := cloneStreamForExternalProxy(stream) switch extPrxy["forceTls"].(string) { case "tls": @@ -153,6 +154,10 @@ func (s *SubClashService) getProxies(inbound *model.Inbound, client model.Client delete(workingStream, "realitySettings") } } + security, _ := workingStream["security"].(string) + if hasExternalProxy { + applyExternalProxyTLSToStream(extPrxy, workingStream, security) + } proxy := s.buildProxy(&workingInbound, client, workingStream, extPrxy["remark"].(string)) if len(proxy) > 0 { @@ -383,6 +388,17 @@ func (s *SubClashService) applySecurity(proxy map[string]any, security string, s if fingerprint, ok := tlsSettings["fingerprint"].(string); ok && fingerprint != "" { proxy["client-fingerprint"] = fingerprint } + if alpn, ok := externalProxyALPNList(tlsSettings["alpn"]); ok { + out := make([]string, 0, len(alpn)) + for _, item := range alpn { + if s, ok := item.(string); ok && s != "" { + out = append(out, s) + } + } + if len(out) > 0 { + proxy["alpn"] = out + } + } } return true case "reality": diff --git a/sub/subJsonService.go b/sub/subJsonService.go index bbc0a381..28a1a9ff 100644 --- a/sub/subJsonService.go +++ b/sub/subJsonService.go @@ -174,7 +174,8 @@ func (s *SubJsonService) getConfig(inbound *model.Inbound, client model.Client, } externalProxies, ok := stream["externalProxy"].([]any) - if !ok || len(externalProxies) == 0 { + hasExternalProxy := ok && len(externalProxies) > 0 + if !hasExternalProxy { externalProxies = []any{ map[string]any{ "forceTls": "same", @@ -191,7 +192,7 @@ func (s *SubJsonService) getConfig(inbound *model.Inbound, client model.Client, extPrxy := ep.(map[string]any) inbound.Listen = extPrxy["dest"].(string) inbound.Port = int(extPrxy["port"].(float64)) - newStream := stream + newStream := cloneStreamForExternalProxy(stream) switch extPrxy["forceTls"].(string) { case "tls": if newStream["security"] != "tls" { @@ -204,6 +205,10 @@ func (s *SubJsonService) getConfig(inbound *model.Inbound, client model.Client, delete(newStream, "tlsSettings") } } + security, _ := newStream["security"].(string) + if hasExternalProxy { + applyExternalProxyTLSToStream(extPrxy, newStream, security) + } streamSettings, _ := json.MarshalIndent(newStream, "", " ") var newOutbounds []json_util.RawMessage diff --git a/sub/subService.go b/sub/subService.go index 364e280b..49a05938 100644 --- a/sub/subService.go +++ b/sub/subService.go @@ -849,11 +849,159 @@ func cloneVmessShareObj(baseObj map[string]any, newSecurity string) map[string]a return newObj } +func applyExternalProxyTLSObj(ep map[string]any, obj map[string]any, security string) { + if security != "tls" { + return + } + if sni, ok := externalProxySNI(ep); ok { + obj["sni"] = sni + } + if fp, ok := ep["fingerprint"].(string); ok && fp != "" { + obj["fp"] = fp + } + if alpn, ok := externalProxyALPN(ep["alpn"]); ok { + obj["alpn"] = alpn + } +} + +func applyExternalProxyTLSParams(ep map[string]any, params map[string]string, security string) { + if security != "tls" { + return + } + if sni, ok := externalProxySNI(ep); ok { + params["sni"] = sni + } + if fp, ok := ep["fingerprint"].(string); ok && fp != "" { + params["fp"] = fp + } + if alpn, ok := externalProxyALPN(ep["alpn"]); ok { + params["alpn"] = alpn + } +} + +// 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) { + if security != "tls" { + return + } + tlsSettings, _ := stream["tlsSettings"].(map[string]any) + if tlsSettings == nil { + tlsSettings = map[string]any{} + stream["tlsSettings"] = tlsSettings + } + if sni, ok := externalProxySNI(ep); ok { + tlsSettings["serverName"] = sni + } + if fp, ok := ep["fingerprint"].(string); ok && fp != "" { + tlsSettings["fingerprint"] = fp + settings, _ := tlsSettings["settings"].(map[string]any) + if settings == nil { + settings = map[string]any{} + tlsSettings["settings"] = settings + } + settings["fingerprint"] = fp + } + if alpn, ok := externalProxyALPNList(ep["alpn"]); ok { + tlsSettings["alpn"] = alpn + } +} + +func externalProxySNI(ep map[string]any) (string, bool) { + if sni, ok := ep["sni"].(string); ok && sni != "" { + return sni, true + } + if dest, ok := ep["dest"].(string); ok && dest != "" { + return dest, true + } + return "", false +} + +func externalProxyALPN(value any) (string, bool) { + switch v := value.(type) { + case string: + return v, v != "" + case []string: + if len(v) == 0 { + return "", false + } + return strings.Join(v, ","), true + case []any: + alpn := make([]string, 0, len(v)) + for _, item := range v { + if s, ok := item.(string); ok && s != "" { + alpn = append(alpn, s) + } + } + if len(alpn) == 0 { + return "", false + } + return strings.Join(alpn, ","), true + default: + return "", false + } +} + +func externalProxyALPNList(value any) ([]any, bool) { + switch v := value.(type) { + case string: + if v == "" { + return nil, false + } + parts := strings.Split(v, ",") + out := make([]any, 0, len(parts)) + for _, part := range parts { + if part = strings.TrimSpace(part); part != "" { + out = append(out, part) + } + } + return out, len(out) > 0 + case []string: + out := make([]any, 0, len(v)) + for _, item := range v { + if item != "" { + out = append(out, item) + } + } + return out, len(out) > 0 + case []any: + out := make([]any, 0, len(v)) + for _, item := range v { + if s, ok := item.(string); ok && s != "" { + out = append(out, s) + } + } + return out, len(out) > 0 + default: + return nil, false + } +} + func (s *SubService) buildVmessExternalProxyLinks(externalProxies []any, baseObj map[string]any, inbound *model.Inbound, email string) string { var links strings.Builder for index, externalProxy := range externalProxies { ep, _ := externalProxy.(map[string]any) newSecurity, _ := ep["forceTls"].(string) + securityToApply := baseObj["tls"].(string) + if newSecurity != "same" { + securityToApply = newSecurity + } newObj := cloneVmessShareObj(baseObj, newSecurity) newObj["ps"] = s.genRemark(inbound, email, ep["remark"].(string)) newObj["add"] = ep["dest"].(string) @@ -862,6 +1010,7 @@ func (s *SubService) buildVmessExternalProxyLinks(externalProxies []any, baseObj if newSecurity != "same" { newObj["tls"] = newSecurity } + applyExternalProxyTLSObj(ep, newObj, securityToApply) if index > 0 { links.WriteString("\n") } @@ -917,11 +1066,14 @@ func (s *SubService) buildExternalProxyURLLinks( securityToApply = newSecurity } + nextParams := cloneStringMap(params) + applyExternalProxyTLSParams(ep, nextParams, securityToApply) + links = append( links, buildLinkWithParamsAndSecurity( makeLink(dest, port), - params, + nextParams, makeRemark(ep), securityToApply, newSecurity == "none", @@ -1052,10 +1204,9 @@ func searchKey(data any, key string) (any, bool) { // - server-only (noSSEHeader, scMaxBufferedPosts, scStreamUpServerSecs, // serverMaxHeaderBytes) — client wouldn't read them, so emitting // them just bloats the URL. -// - client-only (headers, uplinkHTTPMethod, uplinkChunkSize, -// noGRPCHeader, scMinPostsIntervalMs, xmux, downloadSettings) — the -// inbound config doesn't have them; the client configures them -// locally. +// - client-only values are included only when present in the inbound +// JSON. Some deployments/imported configs carry them there, and the +// subscription link is the only place clients can receive them. // // Truthy-only guards keep default inbounds emitting the same compact URL // they did before this helper grew. @@ -1077,15 +1228,12 @@ func buildXhttpExtra(xhttp map[string]any) map[string]any { } } - if mode, ok := xhttp["mode"].(string); ok && len(mode) > 0 { - extra["mode"] = mode - } - stringFields := []string{ + "uplinkHTTPMethod", "sessionPlacement", "sessionKey", "seqPlacement", "seqKey", "uplinkDataPlacement", "uplinkDataKey", - "scMaxEachPostBytes", + "scMaxEachPostBytes", "scMinPostsIntervalMs", } for _, field := range stringFields { if v, ok := xhttp[field].(string); ok && len(v) > 0 { @@ -1093,6 +1241,24 @@ func buildXhttpExtra(xhttp map[string]any) map[string]any { } } + for _, field := range []string{"uplinkChunkSize"} { + if v, ok := nonZeroShareValue(xhttp[field]); ok { + extra[field] = v + } + } + + for _, field := range []string{"noGRPCHeader"} { + if v, ok := xhttp[field].(bool); ok && v { + extra[field] = v + } + } + + for _, field := range []string{"xmux", "downloadSettings"} { + if v, ok := nonEmptyShareObject(xhttp[field]); ok { + extra[field] = v + } + } + // Headers — emitted as the {name: value} map upstream's struct // expects. The server runtime ignores this field, but the client // (consuming the share link) honors it. Drop any "host" entry — @@ -1116,6 +1282,38 @@ func buildXhttpExtra(xhttp map[string]any) map[string]any { return extra } +func nonZeroShareValue(v any) (any, bool) { + switch value := v.(type) { + case string: + return value, value != "" + case int: + return value, value != 0 + case int32: + return value, value != 0 + case int64: + return value, value != 0 + case float32: + return value, value != 0 + case float64: + return value, value != 0 + default: + return nil, false + } +} + +func nonEmptyShareObject(v any) (any, bool) { + switch value := v.(type) { + case map[string]any: + return value, len(value) > 0 + case map[string]string: + return value, len(value) > 0 + case []any: + return value, len(value) > 0 + default: + return nil, false + } +} + // applyXhttpExtraParams emits the full xhttp config into the URL query // params of a vless:// / trojan:// / ss:// link. Sets path/host/mode at // top level (xray's Build() always lets these win over `extra`) and packs diff --git a/sub/subService_test.go b/sub/subService_test.go index f83db7e3..91512d7f 100644 --- a/sub/subService_test.go +++ b/sub/subService_test.go @@ -151,6 +151,77 @@ func TestSearchKey_OnScalar(t *testing.T) { } } +func TestBuildXhttpExtra_IncludesClientSideFieldsWhenPresent(t *testing.T) { + extra := buildXhttpExtra(map[string]any{ + "path": "/xhttp", + "host": "example.com", + "mode": "packet-up", + "xPaddingBytes": "100-1000", + "uplinkHTTPMethod": "GET", + "uplinkChunkSize": float64(4096), + "noGRPCHeader": true, + "scMinPostsIntervalMs": "20-40", + "xmux": map[string]any{ + "maxConcurrency": "16-32", + "hMaxRequestTimes": "600-900", + "hMaxReusableSecs": "1800-3000", + "hKeepAlivePeriod": float64(15), + }, + "downloadSettings": map[string]any{ + "network": "xhttp", + }, + "headers": map[string]any{ + "Host": "ignored.example.com", + "X-Forwarded": "1", + "X-Test-Empty": "", + }, + }) + + if extra["path"] != nil || extra["host"] != nil { + t.Fatalf("path/host should stay top-level, got extra %#v", extra) + } + for _, key := range []string{ + "xPaddingBytes", + "uplinkHTTPMethod", + "uplinkChunkSize", + "noGRPCHeader", + "scMinPostsIntervalMs", + "xmux", + "downloadSettings", + } { + if _, ok := extra[key]; !ok { + t.Fatalf("extra missing %q: %#v", key, extra) + } + } + if _, ok := extra["mode"]; ok { + t.Fatalf("mode should stay as a top-level query parameter, got extra %#v", extra) + } + + headers, ok := extra["headers"].(map[string]any) + if !ok { + t.Fatalf("headers = %#v, want map", extra["headers"]) + } + if _, ok := headers["Host"]; ok { + t.Fatalf("headers should not include Host: %#v", headers) + } + if headers["X-Forwarded"] != "1" { + t.Fatalf("headers[X-Forwarded] = %#v, want 1", headers["X-Forwarded"]) + } +} + +func TestBuildXhttpExtra_LeavesDefaultClientSideFieldsOut(t *testing.T) { + extra := buildXhttpExtra(map[string]any{ + "uplinkHTTPMethod": "", + "uplinkChunkSize": float64(0), + "noGRPCHeader": false, + "xmux": map[string]any{}, + "downloadSettings": map[string]any{}, + }) + if extra != nil { + t.Fatalf("default-only xhttp extra = %#v, want nil", extra) + } +} + func TestCloneStringMap(t *testing.T) { src := map[string]string{"a": "1", "b": "2"} dst := cloneStringMap(src) @@ -369,6 +440,105 @@ func TestCloneVmessShareObj_NoneStripsTLSOnlyKeys(t *testing.T) { } } +func TestApplyExternalProxyTLSParams_UsesProxyDomainAndOverrides(t *testing.T) { + params := map[string]string{ + "security": "tls", + "sni": "origin.example.com", + "fp": "firefox", + "alpn": "h2", + } + ep := map[string]any{ + "dest": "proxy.example.com", + "sni": "tls.example.com", + "fingerprint": "chrome", + "alpn": []any{"h3", "h2"}, + } + + applyExternalProxyTLSParams(ep, params, "tls") + + if params["sni"] != "tls.example.com" { + t.Fatalf("sni = %q, want tls.example.com", params["sni"]) + } + if params["fp"] != "chrome" { + t.Fatalf("fp = %q, want chrome", params["fp"]) + } + if params["alpn"] != "h3,h2" { + t.Fatalf("alpn = %q, want h3,h2", params["alpn"]) + } +} + +func TestApplyExternalProxyTLSParams_FallsBackToDestSNI(t *testing.T) { + params := map[string]string{"security": "tls"} + ep := map[string]any{"dest": "proxy.example.com"} + + applyExternalProxyTLSParams(ep, params, "tls") + + if params["sni"] != "proxy.example.com" { + t.Fatalf("sni = %q, want proxy.example.com", params["sni"]) + } +} + +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) { + params := map[string]string{ + "security": "none", + "sni": "origin.example.com", + } + ep := map[string]any{ + "dest": "proxy.example.com", + "fingerprint": "chrome", + "alpn": []any{"h3"}, + } + + applyExternalProxyTLSParams(ep, params, "none") + + if params["sni"] != "origin.example.com" { + t.Fatalf("sni should not change for security=none, got %q", params["sni"]) + } + if _, ok := params["fp"]; ok { + t.Fatalf("fp should not be set for security=none, got %v", params) + } + if _, ok := params["alpn"]; ok { + t.Fatalf("alpn should not be set for security=none, got %v", params) + } +} + func TestExtractKcpShareFields_Defaults(t *testing.T) { stream := map[string]any{} got := extractKcpShareFields(stream)