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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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