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.
This commit is contained in:
MHSanaei 2026-06-05 10:40:11 +02:00
parent b40f869f2a
commit a8d5d0dfab
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
18 changed files with 67 additions and 16 deletions

View file

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

View file

@ -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({
/>
</Form.Item>
</Field>
<Field label={t('host')}>
<Field label={t('pages.inbounds.address')}>
<Form.Item name={[field.name, 'dest']} noStyle>
<Input placeholder={t('host')} />
<Input placeholder={t('pages.inbounds.address')} />
</Form.Item>
</Field>
<Field label={t('pages.inbounds.port')}>
@ -125,7 +126,7 @@ export default function ExternalProxyForm({
<div className="ext-proxy-grid ext-proxy-grid--tls">
<Field label="SNI">
<Form.Item name={[field.name, 'sni']} noStyle>
<Input placeholder={t('pages.inbounds.form.sniPlaceholder')} />
<Input placeholder={t('pages.inbounds.form.serverNameIndication')} />
</Form.Item>
</Field>
<Field label={t('pages.inbounds.form.fingerprint')}>
@ -157,6 +158,11 @@ export default function ExternalProxyForm({
</Form.Item>
</Field>
</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')}>
<Space.Compact block>
<Form.Item name={[field.name, 'pinnedPeerCertSha256']} noStyle>

View file

@ -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<typeof ExternalProxyEntrySchema>;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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