Explorar o código

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.
Maksim Alekseev hai 10 horas
pai
achega
1f90d2a6ee

+ 0 - 158
.github/copilot-instructions.md

@@ -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

+ 0 - 1
frontend/index.html

@@ -3,7 +3,6 @@
   <head>
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <title>3X-UI</title>
   </head>
   <body>
     <div id="message"></div>

+ 69 - 26
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)
                 });
             });
         }

+ 31 - 3
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
         };
     }
-};
+};

+ 50 - 22
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({
               )}
               <Form.Item label="Server Max Header Bytes"><InputNumber value={ib.stream.xhttp.serverMaxHeaderBytes} min={0} placeholder="0 (default)" onChange={(v) => { ib.stream.xhttp.serverMaxHeaderBytes = Number(v) || 0; refresh(); }} /></Form.Item>
               <Form.Item label="Padding Bytes"><Input value={ib.stream.xhttp.xPaddingBytes} onChange={(e) => { ib.stream.xhttp.xPaddingBytes = e.target.value; refresh(); }} /></Form.Item>
+              <Form.Item label="Uplink HTTP Method">
+                <Select value={ib.stream.xhttp.uplinkHTTPMethod || ''} onChange={(v) => { ib.stream.xhttp.uplinkHTTPMethod = v; refresh(); }}>
+                  <Select.Option value="">Default (POST)</Select.Option>
+                  <Select.Option value="POST">POST</Select.Option>
+                  <Select.Option value="PUT">PUT</Select.Option>
+                  <Select.Option value="GET" disabled={ib.stream.xhttp.mode !== 'packet-up'}>GET (packet-up only)</Select.Option>
+                </Select>
+              </Form.Item>
               <Form.Item label="Padding Obfs Mode"><Switch checked={!!ib.stream.xhttp.xPaddingObfsMode} onChange={(v) => { ib.stream.xhttp.xPaddingObfsMode = v; refresh(); }} /></Form.Item>
               {ib.stream.xhttp.xPaddingObfsMode && (
                 <>
@@ -1686,34 +1697,51 @@ export default function InboundFormModal({
             <Switch checked={externalProxyOn} onChange={setExternalProxy} />
             {externalProxyOn && (
               <Button size="small" type="primary" style={{ marginLeft: 10 }}
-                onClick={() => { ib.stream.externalProxy.push({ forceTls: 'same', dest: '', port: 443, remark: '' }); refresh(); }}>
+                onClick={() => { ib.stream.externalProxy.push({ forceTls: 'same', dest: '', port: 443, remark: '', sni: '', fingerprint: '', alpn: [] }); refresh(); }}>
                 <PlusOutlined />
               </Button>
             )}
           </Form.Item>
           {externalProxyOn && (
             <Form.Item wrapperCol={{ span: 24 }}>
-              {(ib.stream.externalProxy as { forceTls: string; dest: string; port: number; remark: string }[]).map((row, idx) => (
-                <Space.Compact key={`ep-${idx}`} style={{ margin: '8px 0' }} block>
-                  <Tooltip title="Force TLS">
-                    <Select value={row.forceTls} style={{ width: '20%' }} onChange={(v) => { row.forceTls = v; refresh(); }}>
-                      <Select.Option value="same">{t('pages.inbounds.same')}</Select.Option>
-                      <Select.Option value="none">{t('none')}</Select.Option>
-                      <Select.Option value="tls">TLS</Select.Option>
-                    </Select>
-                  </Tooltip>
-                  <Input style={{ width: '30%' }} value={row.dest} placeholder={t('host')}
-                    onChange={(e) => { row.dest = e.target.value; refresh(); }} />
-                  <Tooltip title={t('pages.inbounds.port')}>
-                    <InputNumber value={row.port} style={{ width: '15%' }} min={1} max={65535}
-                      onChange={(v) => { row.port = Number(v) || 0; refresh(); }} />
-                  </Tooltip>
-                  <Input style={{ width: '25%' }} value={row.remark} placeholder={t('pages.inbounds.remark')}
-                    onChange={(e) => { row.remark = e.target.value; refresh(); }} />
-                  <InputAddon onClick={() => { ib.stream.externalProxy.splice(idx, 1); refresh(); }}>
-                    <MinusOutlined />
-                  </InputAddon>
-                </Space.Compact>
+              {(ib.stream.externalProxy as { forceTls: string; dest: string; port: number; remark: string; sni?: string; fingerprint?: string; alpn?: string[] }[]).map((row, idx) => (
+                <div key={`ep-${idx}`} style={{ margin: '8px 0' }}>
+                  <Space.Compact block>
+                    <Tooltip title="Force TLS">
+                      <Select value={row.forceTls} style={{ width: '20%' }} onChange={(v) => { row.forceTls = v; refresh(); }}>
+                        <Select.Option value="same">{t('pages.inbounds.same')}</Select.Option>
+                        <Select.Option value="none">{t('none')}</Select.Option>
+                        <Select.Option value="tls">TLS</Select.Option>
+                      </Select>
+                    </Tooltip>
+                    <Input style={{ width: '30%' }} value={row.dest} placeholder={t('host')}
+                      onChange={(e) => { row.dest = e.target.value; refresh(); }} />
+                    <Tooltip title={t('pages.inbounds.port')}>
+                      <InputNumber value={row.port} style={{ width: '15%' }} min={1} max={65535}
+                        onChange={(v) => { row.port = Number(v) || 0; refresh(); }} />
+                    </Tooltip>
+                    <Input style={{ width: '25%' }} value={row.remark} placeholder={t('pages.inbounds.remark')}
+                      onChange={(e) => { row.remark = e.target.value; refresh(); }} />
+                    <InputAddon onClick={() => { ib.stream.externalProxy.splice(idx, 1); refresh(); }}>
+                      <MinusOutlined />
+                    </InputAddon>
+                  </Space.Compact>
+                  {row.forceTls === 'tls' && (
+                    <Space.Compact style={{ marginTop: 6 }} block>
+                      <Input style={{ width: '30%' }} value={row.sni || ''} placeholder="SNI (defaults to host)"
+                        onChange={(e) => { row.sni = e.target.value; refresh(); }} />
+                      <Select value={row.fingerprint || ''} style={{ width: '30%' }} placeholder="Fingerprint"
+                        onChange={(v) => { row.fingerprint = v; refresh(); }}>
+                        <Select.Option value="">Default</Select.Option>
+                        {FINGERPRINTS.map((fp) => <Select.Option key={fp} value={fp}>{fp}</Select.Option>)}
+                      </Select>
+                      <Select mode="multiple" value={row.alpn || []} style={{ width: '40%' }} placeholder="ALPN"
+                        onChange={(v) => { row.alpn = v; refresh(); }}>
+                        {ALPNS.map((alpn) => <Select.Option key={alpn} value={alpn}>{alpn}</Select.Option>)}
+                      </Select>
+                    </Space.Compact>
+                  )}
+                </div>
               ))}
             </Form.Item>
           )}

+ 18 - 2
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":

+ 7 - 2
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

+ 208 - 10
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

+ 170 - 0
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)