Просмотр исходного кода

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.
MHSanaei 1 день назад
Родитель
Сommit
a8d5d0dfab

+ 2 - 0
frontend/src/lib/xray/inbound-link.ts

@@ -137,6 +137,7 @@ function applyExternalProxyTLSObj(
   if (alpn.length > 0) obj.alpn = alpn;
   if (alpn.length > 0) obj.alpn = alpn;
   const pins = externalProxyPins(externalProxy.pinnedPeerCertSha256);
   const pins = externalProxyPins(externalProxy.pinnedPeerCertSha256);
   if (pins.length > 0) obj.pcs = pins;
   if (pins.length > 0) obj.pcs = pins;
+  if (externalProxy.echConfigList && externalProxy.echConfigList.length > 0) obj.ech = externalProxy.echConfigList;
 }
 }
 
 
 export interface GenVmessLinkInput {
 export interface GenVmessLinkInput {
@@ -280,6 +281,7 @@ function applyExternalProxyTLSParams(
   if (alpn.length > 0) params.set('alpn', alpn);
   if (alpn.length > 0) params.set('alpn', alpn);
   const pins = externalProxyPins(externalProxy.pinnedPeerCertSha256);
   const pins = externalProxyPins(externalProxy.pinnedPeerCertSha256);
   if (pins.length > 0) params.set('pcs', pins);
   if (pins.length > 0) params.set('pcs', pins);
+  if (externalProxy.echConfigList && externalProxy.echConfigList.length > 0) params.set('ech', externalProxy.echConfigList);
 }
 }
 
 
 export interface GenVlessLinkInput {
 export interface GenVlessLinkInput {

+ 9 - 3
frontend/src/pages/inbounds/form/transport/external-proxy.tsx

@@ -16,6 +16,7 @@ const newEntry = () => ({
   fingerprint: '',
   fingerprint: '',
   alpn: [],
   alpn: [],
   pinnedPeerCertSha256: [],
   pinnedPeerCertSha256: [],
+  echConfigList: '',
 });
 });
 
 
 function Field({ label, children }: { label: ReactNode; children: ReactNode }) {
 function Field({ label, children }: { label: ReactNode; children: ReactNode }) {
@@ -92,9 +93,9 @@ export default function ExternalProxyForm({
                                   />
                                   />
                                 </Form.Item>
                                 </Form.Item>
                               </Field>
                               </Field>
-                              <Field label={t('host')}>
+                              <Field label={t('pages.inbounds.address')}>
                                 <Form.Item name={[field.name, 'dest']} noStyle>
                                 <Form.Item name={[field.name, 'dest']} noStyle>
-                                  <Input placeholder={t('host')} />
+                                  <Input placeholder={t('pages.inbounds.address')} />
                                 </Form.Item>
                                 </Form.Item>
                               </Field>
                               </Field>
                               <Field label={t('pages.inbounds.port')}>
                               <Field label={t('pages.inbounds.port')}>
@@ -125,7 +126,7 @@ export default function ExternalProxyForm({
                                     <div className="ext-proxy-grid ext-proxy-grid--tls">
                                     <div className="ext-proxy-grid ext-proxy-grid--tls">
                                       <Field label="SNI">
                                       <Field label="SNI">
                                         <Form.Item name={[field.name, 'sni']} noStyle>
                                         <Form.Item name={[field.name, 'sni']} noStyle>
-                                          <Input placeholder={t('pages.inbounds.form.sniPlaceholder')} />
+                                          <Input placeholder={t('pages.inbounds.form.serverNameIndication')} />
                                         </Form.Item>
                                         </Form.Item>
                                       </Field>
                                       </Field>
                                       <Field label={t('pages.inbounds.form.fingerprint')}>
                                       <Field label={t('pages.inbounds.form.fingerprint')}>
@@ -157,6 +158,11 @@ export default function ExternalProxyForm({
                                         </Form.Item>
                                         </Form.Item>
                                       </Field>
                                       </Field>
                                     </div>
                                     </div>
+                                    <Field label={t('pages.inbounds.form.echConfig')}>
+                                      <Form.Item name={[field.name, 'echConfigList']} noStyle>
+                                        <Input placeholder={t('pages.inbounds.form.echConfig')} />
+                                      </Form.Item>
+                                    </Field>
                                     <Field label={t('pages.inbounds.form.pinnedPeerCertSha256')}>
                                     <Field label={t('pages.inbounds.form.pinnedPeerCertSha256')}>
                                       <Space.Compact block>
                                       <Space.Compact block>
                                         <Form.Item name={[field.name, 'pinnedPeerCertSha256']} noStyle>
                                         <Form.Item name={[field.name, 'pinnedPeerCertSha256']} noStyle>

+ 1 - 0
frontend/src/schemas/protocols/stream/external-proxy.ts

@@ -23,5 +23,6 @@ export const ExternalProxyEntrySchema = z.object({
   ),
   ),
   alpn: z.array(AlpnSchema).optional(),
   alpn: z.array(AlpnSchema).optional(),
   pinnedPeerCertSha256: z.array(z.string()).optional(),
   pinnedPeerCertSha256: z.array(z.string()).optional(),
+  echConfigList: z.string().optional(),
 });
 });
 export type ExternalProxyEntry = z.infer<typeof ExternalProxyEntrySchema>;
 export type ExternalProxyEntry = z.infer<typeof ExternalProxyEntrySchema>;

+ 14 - 0
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 {
 	if pins, ok := externalProxyPins(ep["pinnedPeerCertSha256"]); ok {
 		obj["pcs"] = joinAnyStrings(pins)
 		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) {
 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 {
 	if pins, ok := externalProxyPins(ep["pinnedPeerCertSha256"]); ok {
 		params["pcs"] = joinAnyStrings(pins)
 		params["pcs"] = joinAnyStrings(pins)
 	}
 	}
+	if ech, ok := ep["echConfigList"].(string); ok && ech != "" {
+		params["ech"] = ech
+	}
 }
 }
 
 
 // applyExternalProxyHysteriaParams overrides the cert pin for a single
 // 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
 		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) {
 func externalProxySNI(ep map[string]any) (string, bool) {

+ 41 - 0
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) {
 func TestApplyExternalProxyTLSToStream_DoesNotLeakAcrossProxies(t *testing.T) {
 	stream := map[string]any{
 	stream := map[string]any{
 		"security": "tls",
 		"security": "tls",

+ 0 - 1
web/translation/ar-EG.json

@@ -551,7 +551,6 @@
         "maxSendingWindow": "أقصى نافذة إرسال",
         "maxSendingWindow": "أقصى نافذة إرسال",
         "externalProxy": "وكيل خارجي",
         "externalProxy": "وكيل خارجي",
         "forceTls": "فرض TLS",
         "forceTls": "فرض TLS",
-        "sniPlaceholder": "SNI (افتراضياً host)",
         "fingerprint": "بصمة",
         "fingerprint": "بصمة",
         "defaultOption": "افتراضي",
         "defaultOption": "افتراضي",
         "routeMark": "Route Mark",
         "routeMark": "Route Mark",

+ 0 - 1
web/translation/en-US.json

@@ -552,7 +552,6 @@
         "maxSendingWindow": "Max Sending Window",
         "maxSendingWindow": "Max Sending Window",
         "externalProxy": "External Proxy",
         "externalProxy": "External Proxy",
         "forceTls": "Force TLS",
         "forceTls": "Force TLS",
-        "sniPlaceholder": "SNI (defaults to host)",
         "fingerprint": "Fingerprint",
         "fingerprint": "Fingerprint",
         "defaultOption": "Default",
         "defaultOption": "Default",
         "routeMark": "Route Mark",
         "routeMark": "Route Mark",

+ 0 - 1
web/translation/es-ES.json

@@ -551,7 +551,6 @@
         "maxSendingWindow": "Máx. ventana de envío",
         "maxSendingWindow": "Máx. ventana de envío",
         "externalProxy": "Proxy externo",
         "externalProxy": "Proxy externo",
         "forceTls": "Forzar TLS",
         "forceTls": "Forzar TLS",
-        "sniPlaceholder": "SNI (por defecto = host)",
         "fingerprint": "Fingerprint",
         "fingerprint": "Fingerprint",
         "defaultOption": "Por defecto",
         "defaultOption": "Por defecto",
         "routeMark": "Route Mark",
         "routeMark": "Route Mark",

+ 0 - 1
web/translation/fa-IR.json

@@ -551,7 +551,6 @@
         "maxSendingWindow": "حداکثر پنجره ارسال",
         "maxSendingWindow": "حداکثر پنجره ارسال",
         "externalProxy": "پراکسی خارجی",
         "externalProxy": "پراکسی خارجی",
         "forceTls": "اجبار TLS",
         "forceTls": "اجبار TLS",
-        "sniPlaceholder": "SNI (پیش‌فرض همان host)",
         "fingerprint": "اثرانگشت",
         "fingerprint": "اثرانگشت",
         "defaultOption": "پیش‌فرض",
         "defaultOption": "پیش‌فرض",
         "routeMark": "علامت مسیر",
         "routeMark": "علامت مسیر",

+ 0 - 1
web/translation/id-ID.json

@@ -551,7 +551,6 @@
         "maxSendingWindow": "Maks. jendela pengiriman",
         "maxSendingWindow": "Maks. jendela pengiriman",
         "externalProxy": "Proxy eksternal",
         "externalProxy": "Proxy eksternal",
         "forceTls": "Paksa TLS",
         "forceTls": "Paksa TLS",
-        "sniPlaceholder": "SNI (default = host)",
         "fingerprint": "Fingerprint",
         "fingerprint": "Fingerprint",
         "defaultOption": "Default",
         "defaultOption": "Default",
         "routeMark": "Route Mark",
         "routeMark": "Route Mark",

+ 0 - 1
web/translation/ja-JP.json

@@ -551,7 +551,6 @@
         "maxSendingWindow": "最大送信ウィンドウ",
         "maxSendingWindow": "最大送信ウィンドウ",
         "externalProxy": "外部プロキシ",
         "externalProxy": "外部プロキシ",
         "forceTls": "TLS を強制",
         "forceTls": "TLS を強制",
-        "sniPlaceholder": "SNI (デフォルトは host)",
         "fingerprint": "Fingerprint",
         "fingerprint": "Fingerprint",
         "defaultOption": "デフォルト",
         "defaultOption": "デフォルト",
         "routeMark": "Route Mark",
         "routeMark": "Route Mark",

+ 0 - 1
web/translation/pt-BR.json

@@ -551,7 +551,6 @@
         "maxSendingWindow": "Máx. janela de envio",
         "maxSendingWindow": "Máx. janela de envio",
         "externalProxy": "Proxy externo",
         "externalProxy": "Proxy externo",
         "forceTls": "Forçar TLS",
         "forceTls": "Forçar TLS",
-        "sniPlaceholder": "SNI (padrão = host)",
         "fingerprint": "Fingerprint",
         "fingerprint": "Fingerprint",
         "defaultOption": "Padrão",
         "defaultOption": "Padrão",
         "routeMark": "Route Mark",
         "routeMark": "Route Mark",

+ 0 - 1
web/translation/ru-RU.json

@@ -551,7 +551,6 @@
         "maxSendingWindow": "Макс. окно отправки",
         "maxSendingWindow": "Макс. окно отправки",
         "externalProxy": "External Proxy",
         "externalProxy": "External Proxy",
         "forceTls": "Принудительный TLS",
         "forceTls": "Принудительный TLS",
-        "sniPlaceholder": "SNI (по умолчанию = host)",
         "fingerprint": "Fingerprint",
         "fingerprint": "Fingerprint",
         "defaultOption": "По умолчанию",
         "defaultOption": "По умолчанию",
         "routeMark": "Route Mark",
         "routeMark": "Route Mark",

+ 0 - 1
web/translation/tr-TR.json

@@ -551,7 +551,6 @@
         "maxSendingWindow": "Maks. gönderme penceresi",
         "maxSendingWindow": "Maks. gönderme penceresi",
         "externalProxy": "Harici proxy",
         "externalProxy": "Harici proxy",
         "forceTls": "TLS zorla",
         "forceTls": "TLS zorla",
-        "sniPlaceholder": "SNI (varsayılan host)",
         "fingerprint": "Fingerprint",
         "fingerprint": "Fingerprint",
         "defaultOption": "Varsayılan",
         "defaultOption": "Varsayılan",
         "routeMark": "Route Mark",
         "routeMark": "Route Mark",

+ 0 - 1
web/translation/uk-UA.json

@@ -551,7 +551,6 @@
         "maxSendingWindow": "Макс. вікно відправки",
         "maxSendingWindow": "Макс. вікно відправки",
         "externalProxy": "External Proxy",
         "externalProxy": "External Proxy",
         "forceTls": "Примусовий TLS",
         "forceTls": "Примусовий TLS",
-        "sniPlaceholder": "SNI (за замовчуванням = host)",
         "fingerprint": "Fingerprint",
         "fingerprint": "Fingerprint",
         "defaultOption": "За замовчуванням",
         "defaultOption": "За замовчуванням",
         "routeMark": "Route Mark",
         "routeMark": "Route Mark",

+ 0 - 1
web/translation/vi-VN.json

@@ -551,7 +551,6 @@
         "maxSendingWindow": "Cửa sổ gửi tối đa",
         "maxSendingWindow": "Cửa sổ gửi tối đa",
         "externalProxy": "Proxy ngoài",
         "externalProxy": "Proxy ngoài",
         "forceTls": "Bắt buộc TLS",
         "forceTls": "Bắt buộc TLS",
-        "sniPlaceholder": "SNI (mặc định = host)",
         "fingerprint": "Fingerprint",
         "fingerprint": "Fingerprint",
         "defaultOption": "Mặc định",
         "defaultOption": "Mặc định",
         "routeMark": "Route Mark",
         "routeMark": "Route Mark",

+ 0 - 1
web/translation/zh-CN.json

@@ -551,7 +551,6 @@
         "maxSendingWindow": "最大发送窗口",
         "maxSendingWindow": "最大发送窗口",
         "externalProxy": "外部代理",
         "externalProxy": "外部代理",
         "forceTls": "强制 TLS",
         "forceTls": "强制 TLS",
-        "sniPlaceholder": "SNI (默认为 host)",
         "fingerprint": "指纹",
         "fingerprint": "指纹",
         "defaultOption": "默认",
         "defaultOption": "默认",
         "routeMark": "Route Mark",
         "routeMark": "Route Mark",

+ 0 - 1
web/translation/zh-TW.json

@@ -551,7 +551,6 @@
         "maxSendingWindow": "最大發送視窗",
         "maxSendingWindow": "最大發送視窗",
         "externalProxy": "外部代理",
         "externalProxy": "外部代理",
         "forceTls": "強制 TLS",
         "forceTls": "強制 TLS",
-        "sniPlaceholder": "SNI (預設為 host)",
         "fingerprint": "指紋",
         "fingerprint": "指紋",
         "defaultOption": "預設",
         "defaultOption": "預設",
         "routeMark": "Route Mark",
         "routeMark": "Route Mark",