mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 20:54:14 +00:00
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:
parent
b40f869f2a
commit
a8d5d0dfab
18 changed files with 67 additions and 16 deletions
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -551,7 +551,6 @@
|
||||||
"maxSendingWindow": "حداکثر پنجره ارسال",
|
"maxSendingWindow": "حداکثر پنجره ارسال",
|
||||||
"externalProxy": "پراکسی خارجی",
|
"externalProxy": "پراکسی خارجی",
|
||||||
"forceTls": "اجبار TLS",
|
"forceTls": "اجبار TLS",
|
||||||
"sniPlaceholder": "SNI (پیشفرض همان host)",
|
|
||||||
"fingerprint": "اثرانگشت",
|
"fingerprint": "اثرانگشت",
|
||||||
"defaultOption": "پیشفرض",
|
"defaultOption": "پیشفرض",
|
||||||
"routeMark": "علامت مسیر",
|
"routeMark": "علامت مسیر",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue