From a8d5d0dfab653caa89ac59d652d72a6e26beb053 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Fri, 5 Jun 2026 10:40:11 +0200 Subject: [PATCH] fix(external-proxy): relabel "Host" as "Address", add per-entry ECH (#4935) The external proxy "Host" field was bound to dest (the connection address that becomes the link host) but labeled "Host", misleading users into thinking it set a transport host header. Relabel it to "Address" to match what it actually controls. Add per-entry ECH (echConfigList) to the external proxy schema, form (shown under Force TLS = TLS), the TS link generator, and the Go sub services: ech is emitted on share links and vmess objects, and written into the stream so the JSON subscription picks it up via the existing tlsData reader. --- frontend/src/lib/xray/inbound-link.ts | 2 + .../form/transport/external-proxy.tsx | 12 ++++-- .../protocols/stream/external-proxy.ts | 1 + sub/subService.go | 14 +++++++ sub/subService_test.go | 41 +++++++++++++++++++ web/translation/ar-EG.json | 1 - web/translation/en-US.json | 1 - web/translation/es-ES.json | 1 - web/translation/fa-IR.json | 1 - web/translation/id-ID.json | 1 - web/translation/ja-JP.json | 1 - web/translation/pt-BR.json | 1 - web/translation/ru-RU.json | 1 - web/translation/tr-TR.json | 1 - web/translation/uk-UA.json | 1 - web/translation/vi-VN.json | 1 - web/translation/zh-CN.json | 1 - web/translation/zh-TW.json | 1 - 18 files changed, 67 insertions(+), 16 deletions(-) diff --git a/frontend/src/lib/xray/inbound-link.ts b/frontend/src/lib/xray/inbound-link.ts index 55f2afd5..1d8538e9 100644 --- a/frontend/src/lib/xray/inbound-link.ts +++ b/frontend/src/lib/xray/inbound-link.ts @@ -137,6 +137,7 @@ function applyExternalProxyTLSObj( if (alpn.length > 0) obj.alpn = alpn; const pins = externalProxyPins(externalProxy.pinnedPeerCertSha256); if (pins.length > 0) obj.pcs = pins; + if (externalProxy.echConfigList && externalProxy.echConfigList.length > 0) obj.ech = externalProxy.echConfigList; } export interface GenVmessLinkInput { @@ -280,6 +281,7 @@ function applyExternalProxyTLSParams( if (alpn.length > 0) params.set('alpn', alpn); const pins = externalProxyPins(externalProxy.pinnedPeerCertSha256); if (pins.length > 0) params.set('pcs', pins); + if (externalProxy.echConfigList && externalProxy.echConfigList.length > 0) params.set('ech', externalProxy.echConfigList); } export interface GenVlessLinkInput { diff --git a/frontend/src/pages/inbounds/form/transport/external-proxy.tsx b/frontend/src/pages/inbounds/form/transport/external-proxy.tsx index f8a354bf..16cb34f6 100644 --- a/frontend/src/pages/inbounds/form/transport/external-proxy.tsx +++ b/frontend/src/pages/inbounds/form/transport/external-proxy.tsx @@ -16,6 +16,7 @@ const newEntry = () => ({ fingerprint: '', alpn: [], pinnedPeerCertSha256: [], + echConfigList: '', }); function Field({ label, children }: { label: ReactNode; children: ReactNode }) { @@ -92,9 +93,9 @@ export default function ExternalProxyForm({ /> - + - + @@ -125,7 +126,7 @@ export default function ExternalProxyForm({
- + @@ -157,6 +158,11 @@ export default function ExternalProxyForm({
+ + + + + diff --git a/frontend/src/schemas/protocols/stream/external-proxy.ts b/frontend/src/schemas/protocols/stream/external-proxy.ts index a74c5967..a6efb4cb 100644 --- a/frontend/src/schemas/protocols/stream/external-proxy.ts +++ b/frontend/src/schemas/protocols/stream/external-proxy.ts @@ -23,5 +23,6 @@ export const ExternalProxyEntrySchema = z.object({ ), alpn: z.array(AlpnSchema).optional(), pinnedPeerCertSha256: z.array(z.string()).optional(), + echConfigList: z.string().optional(), }); export type ExternalProxyEntry = z.infer; diff --git a/sub/subService.go b/sub/subService.go index 169f06e1..11e77441 100644 --- a/sub/subService.go +++ b/sub/subService.go @@ -1053,6 +1053,9 @@ func applyExternalProxyTLSObj(ep map[string]any, obj map[string]any, security st if pins, ok := externalProxyPins(ep["pinnedPeerCertSha256"]); ok { obj["pcs"] = joinAnyStrings(pins) } + if ech, ok := ep["echConfigList"].(string); ok && ech != "" { + obj["ech"] = ech + } } func applyExternalProxyTLSParams(ep map[string]any, params map[string]string, security string) { @@ -1071,6 +1074,9 @@ func applyExternalProxyTLSParams(ep map[string]any, params map[string]string, se if pins, ok := externalProxyPins(ep["pinnedPeerCertSha256"]); ok { params["pcs"] = joinAnyStrings(pins) } + if ech, ok := ep["echConfigList"].(string); ok && ech != "" { + params["ech"] = ech + } } // applyExternalProxyHysteriaParams overrides the cert pin for a single @@ -1143,6 +1149,14 @@ func applyExternalProxyTLSToStream(ep map[string]any, stream map[string]any, sec } settings["pinnedPeerCertSha256"] = pins } + if ech, ok := ep["echConfigList"].(string); ok && ech != "" { + settings, _ := tlsSettings["settings"].(map[string]any) + if settings == nil { + settings = map[string]any{} + tlsSettings["settings"] = settings + } + settings["echConfigList"] = ech + } } func externalProxySNI(ep map[string]any) (string, bool) { diff --git a/sub/subService_test.go b/sub/subService_test.go index f7eceea8..a4d538c5 100644 --- a/sub/subService_test.go +++ b/sub/subService_test.go @@ -575,6 +575,47 @@ func TestApplyExternalProxyTLSParams_ExplicitSNIOverridesUpstream(t *testing.T) } } +func TestApplyExternalProxy_ECHPropagates(t *testing.T) { + const ech = "ech-config-base64" + + t.Run("url params", func(t *testing.T) { + params := map[string]string{"security": "tls"} + ep := map[string]any{"dest": "proxy.example.com", "echConfigList": ech} + applyExternalProxyTLSParams(ep, params, "tls") + if params["ech"] != ech { + t.Fatalf("ech param = %q, want %q", params["ech"], ech) + } + }) + + t.Run("vmess obj", func(t *testing.T) { + obj := map[string]any{} + ep := map[string]any{"dest": "proxy.example.com", "echConfigList": ech} + applyExternalProxyTLSObj(ep, obj, "tls") + if obj["ech"] != ech { + t.Fatalf("ech obj = %v, want %q", obj["ech"], ech) + } + }) + + t.Run("json stream settings", func(t *testing.T) { + stream := map[string]any{"security": "tls", "tlsSettings": map[string]any{}} + ep := map[string]any{"dest": "proxy.example.com", "echConfigList": ech} + applyExternalProxyTLSToStream(ep, stream, "tls") + settings, _ := stream["tlsSettings"].(map[string]any)["settings"].(map[string]any) + if settings["echConfigList"] != ech { + t.Fatalf("echConfigList = %v, want %q", settings["echConfigList"], ech) + } + }) + + t.Run("non-tls security drops ech", func(t *testing.T) { + params := map[string]string{} + ep := map[string]any{"echConfigList": ech} + applyExternalProxyTLSParams(ep, params, "none") + if _, ok := params["ech"]; ok { + t.Fatalf("ech must not be set when security != tls") + } + }) +} + func TestApplyExternalProxyTLSToStream_DoesNotLeakAcrossProxies(t *testing.T) { stream := map[string]any{ "security": "tls", diff --git a/web/translation/ar-EG.json b/web/translation/ar-EG.json index 668ceb37..0def8de6 100644 --- a/web/translation/ar-EG.json +++ b/web/translation/ar-EG.json @@ -551,7 +551,6 @@ "maxSendingWindow": "أقصى نافذة إرسال", "externalProxy": "وكيل خارجي", "forceTls": "فرض TLS", - "sniPlaceholder": "SNI (افتراضياً host)", "fingerprint": "بصمة", "defaultOption": "افتراضي", "routeMark": "Route Mark", diff --git a/web/translation/en-US.json b/web/translation/en-US.json index eb78e116..da0ecd4e 100644 --- a/web/translation/en-US.json +++ b/web/translation/en-US.json @@ -552,7 +552,6 @@ "maxSendingWindow": "Max Sending Window", "externalProxy": "External Proxy", "forceTls": "Force TLS", - "sniPlaceholder": "SNI (defaults to host)", "fingerprint": "Fingerprint", "defaultOption": "Default", "routeMark": "Route Mark", diff --git a/web/translation/es-ES.json b/web/translation/es-ES.json index d4e84936..d0b609ec 100644 --- a/web/translation/es-ES.json +++ b/web/translation/es-ES.json @@ -551,7 +551,6 @@ "maxSendingWindow": "Máx. ventana de envío", "externalProxy": "Proxy externo", "forceTls": "Forzar TLS", - "sniPlaceholder": "SNI (por defecto = host)", "fingerprint": "Fingerprint", "defaultOption": "Por defecto", "routeMark": "Route Mark", diff --git a/web/translation/fa-IR.json b/web/translation/fa-IR.json index cfa5d27a..e1987166 100644 --- a/web/translation/fa-IR.json +++ b/web/translation/fa-IR.json @@ -551,7 +551,6 @@ "maxSendingWindow": "حداکثر پنجره ارسال", "externalProxy": "پراکسی خارجی", "forceTls": "اجبار TLS", - "sniPlaceholder": "SNI (پیش‌فرض همان host)", "fingerprint": "اثرانگشت", "defaultOption": "پیش‌فرض", "routeMark": "علامت مسیر", diff --git a/web/translation/id-ID.json b/web/translation/id-ID.json index 74c7878b..1f89ec24 100644 --- a/web/translation/id-ID.json +++ b/web/translation/id-ID.json @@ -551,7 +551,6 @@ "maxSendingWindow": "Maks. jendela pengiriman", "externalProxy": "Proxy eksternal", "forceTls": "Paksa TLS", - "sniPlaceholder": "SNI (default = host)", "fingerprint": "Fingerprint", "defaultOption": "Default", "routeMark": "Route Mark", diff --git a/web/translation/ja-JP.json b/web/translation/ja-JP.json index 9f14c4f1..728226ac 100644 --- a/web/translation/ja-JP.json +++ b/web/translation/ja-JP.json @@ -551,7 +551,6 @@ "maxSendingWindow": "最大送信ウィンドウ", "externalProxy": "外部プロキシ", "forceTls": "TLS を強制", - "sniPlaceholder": "SNI (デフォルトは host)", "fingerprint": "Fingerprint", "defaultOption": "デフォルト", "routeMark": "Route Mark", diff --git a/web/translation/pt-BR.json b/web/translation/pt-BR.json index b7ccc449..982b3a36 100644 --- a/web/translation/pt-BR.json +++ b/web/translation/pt-BR.json @@ -551,7 +551,6 @@ "maxSendingWindow": "Máx. janela de envio", "externalProxy": "Proxy externo", "forceTls": "Forçar TLS", - "sniPlaceholder": "SNI (padrão = host)", "fingerprint": "Fingerprint", "defaultOption": "Padrão", "routeMark": "Route Mark", diff --git a/web/translation/ru-RU.json b/web/translation/ru-RU.json index bdda60b5..6f7f32c3 100644 --- a/web/translation/ru-RU.json +++ b/web/translation/ru-RU.json @@ -551,7 +551,6 @@ "maxSendingWindow": "Макс. окно отправки", "externalProxy": "External Proxy", "forceTls": "Принудительный TLS", - "sniPlaceholder": "SNI (по умолчанию = host)", "fingerprint": "Fingerprint", "defaultOption": "По умолчанию", "routeMark": "Route Mark", diff --git a/web/translation/tr-TR.json b/web/translation/tr-TR.json index f8f8f5c9..57c52972 100644 --- a/web/translation/tr-TR.json +++ b/web/translation/tr-TR.json @@ -551,7 +551,6 @@ "maxSendingWindow": "Maks. gönderme penceresi", "externalProxy": "Harici proxy", "forceTls": "TLS zorla", - "sniPlaceholder": "SNI (varsayılan host)", "fingerprint": "Fingerprint", "defaultOption": "Varsayılan", "routeMark": "Route Mark", diff --git a/web/translation/uk-UA.json b/web/translation/uk-UA.json index 8c602380..dca5dd98 100644 --- a/web/translation/uk-UA.json +++ b/web/translation/uk-UA.json @@ -551,7 +551,6 @@ "maxSendingWindow": "Макс. вікно відправки", "externalProxy": "External Proxy", "forceTls": "Примусовий TLS", - "sniPlaceholder": "SNI (за замовчуванням = host)", "fingerprint": "Fingerprint", "defaultOption": "За замовчуванням", "routeMark": "Route Mark", diff --git a/web/translation/vi-VN.json b/web/translation/vi-VN.json index a56cfb21..4455f03b 100644 --- a/web/translation/vi-VN.json +++ b/web/translation/vi-VN.json @@ -551,7 +551,6 @@ "maxSendingWindow": "Cửa sổ gửi tối đa", "externalProxy": "Proxy ngoài", "forceTls": "Bắt buộc TLS", - "sniPlaceholder": "SNI (mặc định = host)", "fingerprint": "Fingerprint", "defaultOption": "Mặc định", "routeMark": "Route Mark", diff --git a/web/translation/zh-CN.json b/web/translation/zh-CN.json index 9b759532..82685885 100644 --- a/web/translation/zh-CN.json +++ b/web/translation/zh-CN.json @@ -551,7 +551,6 @@ "maxSendingWindow": "最大发送窗口", "externalProxy": "外部代理", "forceTls": "强制 TLS", - "sniPlaceholder": "SNI (默认为 host)", "fingerprint": "指纹", "defaultOption": "默认", "routeMark": "Route Mark", diff --git a/web/translation/zh-TW.json b/web/translation/zh-TW.json index 157f75d5..636d7352 100644 --- a/web/translation/zh-TW.json +++ b/web/translation/zh-TW.json @@ -551,7 +551,6 @@ "maxSendingWindow": "最大發送視窗", "externalProxy": "外部代理", "forceTls": "強制 TLS", - "sniPlaceholder": "SNI (預設為 host)", "fingerprint": "指紋", "defaultOption": "預設", "routeMark": "Route Mark",