From adad6afb8ea616258d012883b4cb936eef98f964 Mon Sep 17 00:00:00 2001 From: Toximiner Date: Mon, 12 Oct 2026 22:13:53 +0300 Subject: [PATCH 1/4] fix(sub): carry a reality host's fingerprint into Clash and JSON applyHostStreamOverrides copied a reality host's SNI into the client stream but not its fingerprint, so the vless:// link for a host said fp=firefox while the Clash proxy and JSON outbound for the same host kept the inbound's chrome. Apply the host fingerprint next to the SNI. --- internal/sub/host_sub.go | 3 +++ internal/sub/host_sub_test.go | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/internal/sub/host_sub.go b/internal/sub/host_sub.go index 6dabec503..a8976a865 100644 --- a/internal/sub/host_sub.go +++ b/internal/sub/host_sub.go @@ -170,6 +170,9 @@ func applyHostStreamOverrides(ep map[string]any, stream map[string]any) { if sni, ok := externalProxySNI(ep); ok { rs["serverName"] = sni } + if fp, ok := ep["fingerprint"].(string); ok && fp != "" { + rs["fingerprint"] = fp + } } } } diff --git a/internal/sub/host_sub_test.go b/internal/sub/host_sub_test.go index 6cd02ba0b..e26e1e85b 100644 --- a/internal/sub/host_sub_test.go +++ b/internal/sub/host_sub_test.go @@ -516,6 +516,38 @@ func TestSub_HostRealitySniJSONAndClash(t *testing.T) { } } +// A reality host's fingerprint must reach JSON and Clash like it reaches the raw +// link; otherwise one host gets fp=firefox in vless:// but chrome in Clash. +func TestSub_HostRealityFingerprintJSONAndClash(t *testing.T) { + seedSubDB(t) + realityStream := `{"network":"tcp","security":"reality","tcpSettings":{"header":{"type":"none"}},"realitySettings":{"serverNames":["base.reality.com"],"shortIds":["abcd"],"settings":{"publicKey":"PBK","fingerprint":"chrome"}}}` + ib := seedSubInbound(t, "s1", "rlf", 4492, 1, realityStream) + seedHost(t, &model.Host{ + InboundId: ib.Id, SortOrder: 0, Remark: "RLF", Address: "rl.cdn.com", Port: 8443, + Security: "reality", Sni: "host.reality.com", Fingerprint: "firefox", + }) + + out, _, err := NewSubJsonService("", "", "", "", NewSubService("")).GetJson("s1", "req.example.com", false) + if err != nil { + t.Fatalf("GetJson: %v", err) + } + var doc map[string]any + if err := json.Unmarshal([]byte(out), &doc); err != nil { + t.Fatalf("a single-config subscription should be one JSON object: %v\n%s", err, out) + } + if got := proxyRealitySettings(t, doc)["fingerprint"]; got != "firefox" { + t.Fatalf("json reality fingerprint = %v, want the host's firefox", got) + } + + yaml, _, err := NewSubClashService(false, "", NewSubService("")).GetClash("s1", "req.example.com") + if err != nil { + t.Fatalf("GetClash: %v", err) + } + if !strings.Contains(yaml, "client-fingerprint: firefox") { + t.Fatalf("clash proxy should carry the host's fingerprint:\n%s", yaml) + } +} + func proxyRealitySettings(t *testing.T, doc map[string]any) map[string]any { t.Helper() outbounds, _ := doc["outbounds"].([]any) From 3660650c4b9cc0ba741961c223c498df860de163 Mon Sep 17 00:00:00 2001 From: Toximiner Date: Mon, 12 Oct 2026 22:19:50 +0300 Subject: [PATCH 2/4] feat(sub): make the host "Mihomo X25519" switch drop ML-KEM The switch was stored since #5409 but never read by any renderer, and #6451 then forced support-x25519mlkem768: true for every REALITY proxy. Servers on Xray-core older than 26.9.8 accept a plain X25519 hello, and some networks drop the two-segment ML-KEM ClientHello while letting the one-segment X25519 hello through, so those deployments had no way back. A host with the switch on now gets support-x25519mlkem768: false in its Clash proxy and no ML-KEM hint in its vless:// link; every other host keeps the current output. Off is the default, so no migration is needed. Adds the switch tooltip in all locales and documents it in reality.mdx. --- docs/content/docs/en/config/reality.mdx | 8 ++- docs/content/docs/fa/config/reality.mdx | 12 +++- docs/content/docs/ru/config/reality.mdx | 10 +-- docs/content/docs/zh/config/reality.mdx | 2 +- frontend/src/pages/hosts/HostFormModal.tsx | 1 + internal/sub/clash_service.go | 7 ++ internal/sub/host_sub.go | 13 ++++ internal/sub/host_sub_test.go | 84 ++++++++++++++++++++++ internal/web/translation/ar-EG.json | 1 + internal/web/translation/en-US.json | 1 + internal/web/translation/es-ES.json | 1 + internal/web/translation/fa-IR.json | 1 + internal/web/translation/id-ID.json | 1 + internal/web/translation/ja-JP.json | 1 + internal/web/translation/pt-BR.json | 1 + internal/web/translation/ru-RU.json | 1 + internal/web/translation/tr-TR.json | 1 + internal/web/translation/uk-UA.json | 1 + internal/web/translation/vi-VN.json | 1 + internal/web/translation/zh-CN.json | 1 + internal/web/translation/zh-TW.json | 1 + 21 files changed, 139 insertions(+), 11 deletions(-) diff --git a/docs/content/docs/en/config/reality.mdx b/docs/content/docs/en/config/reality.mdx index 4fa83c6ae..2bd717076 100644 --- a/docs/content/docs/en/config/reality.mdx +++ b/docs/content/docs/en/config/reality.mdx @@ -131,9 +131,11 @@ vless://@:443?security=reality&pbk=&sid=&sni with Mihomo's uTLS v1.8.7); enabling the flag cannot upgrade an old fingerprint. Raw `vless://` links carry the equivalent `support-x25519mlkem768=true` hint; clients that support this URI extension, including current Mihomo builds, apply - it on import. Very old REALITY servers that reject ML-KEM require a per-node - client override setting this option to `false`, or a server upgrade. Clearing - the version limit alone does not fix the handshake. + it on import. Servers on Xray-core older than 26.9.8 accept a plain `X25519` + hello, and very old ones reject ML-KEM outright. For those, or where a network + drops the larger ML-KEM ClientHello, enable **Mihomo X25519** on a Host: its + Clash proxy gets `support-x25519mlkem768: false` and its `vless://` link drops + the hint. Clearing the version limit alone does not fix the handshake. diff --git a/docs/content/docs/fa/config/reality.mdx b/docs/content/docs/fa/config/reality.mdx index 5f2f44369..87d006845 100644 --- a/docs/content/docs/fa/config/reality.mdx +++ b/docs/content/docs/fa/config/reality.mdx @@ -140,9 +140,15 @@ vless://@:443?security=reality&pbk=&sid=&sni راهنمای معادل `support-x25519mlkem768=true` را منتقل می‌کند؛ کلاینت‌هایی که این افزونهٔ URI را پشتیبانی می‌کنند، از جمله نسخه‌های فعلی Mihomo، آن را هنگام ورود اعمال می‌کنند. - برای سرورهای بسیار قدیمی که ML-KEM را رد می‌کنند، گزینه را برای همان گره در کلاینت روی - `false` - بگذارید یا سرور را ارتقا دهید. حذف محدودیت نسخه به‌تنهایی دست‌دهی را اصلاح نمی‌کند. + سرورهای Xray-core قدیمی‌تر از 26.9.8 دست‌دهی سادهٔ + `X25519` + را می‌پذیرند و سرورهای بسیار قدیمی ML-KEM را رد می‌کنند. برای آن‌ها، یا اگر شبکه ClientHello بزرگ‌تر ML-KEM را حذف می‌کند، گزینهٔ + **Mihomo X25519** + را در میزبان (Host) فعال کنید: پروکسی Clash آن مقدار + `support-x25519mlkem768: false` + را می‌گیرد و لینک + `vless://` + آن راهنما را ندارد. حذف محدودیت نسخه به‌تنهایی دست‌دهی را اصلاح نمی‌کند. diff --git a/docs/content/docs/ru/config/reality.mdx b/docs/content/docs/ru/config/reality.mdx index b4cd5b632..ea364fe47 100644 --- a/docs/content/docs/ru/config/reality.mdx +++ b/docs/content/docs/ru/config/reality.mdx @@ -135,10 +135,12 @@ vless://@:443?security=reality&pbk=&sid=&sni нужен отпечаток с ML-KEM (`chrome` при uTLS v1.8.7 в Mihomo). Сам флаг не обновляет старые отпечатки. Исходные ссылки `vless://` передают эквивалентную подсказку `support-x25519mlkem768=true`; клиенты, поддерживающие это расширение - URI, включая актуальные сборки Mihomo, применяют её при импорте. Для очень старых - серверов REALITY, отвергающих ML-KEM, задайте `false` для соответствующего узла - в клиенте или обновите сервер. Снятие ограничения версии не исправляет это - рукопожатие. + URI, включая актуальные сборки Mihomo, применяют её при импорте. Серверы на + Xray-core ниже 26.9.8 принимают обычный `X25519`, а очень старые отвергают + ML-KEM. Для них, а также если сеть отбрасывает крупный ClientHello с ML-KEM, + включите **Mihomo X25519** в хосте: его прокси в Clash получит + `support-x25519mlkem768: false`, а ссылка `vless://` — без подсказки. Снятие + ограничения версии не исправляет это рукопожатие. diff --git a/docs/content/docs/zh/config/reality.mdx b/docs/content/docs/zh/config/reality.mdx index 1eaeb2854..33e779aa9 100644 --- a/docs/content/docs/zh/config/reality.mdx +++ b/docs/content/docs/zh/config/reality.mdx @@ -106,7 +106,7 @@ vless://@:443?security=reality&pbk=&sid=&sni - **私钥泄露。** 永远只把**公钥**分发给客户端。 - **流控设置错误。** REALITY + XTLS-Vision 要求在入站的客户端条目和分享链接上都设置 `flow = xtls-rprx-vision`。 - **客户端版本限制。** Xray-core v26.9.8+ 在**最小客户端版本**留空时不再设置默认下限,但已明确保存的限制仍生效。较早的内核可能使用内置下限(如 `26.3.27`),导致第三方客户端即使密钥正确也被拒绝。修改前先核对运行中的内核版本;降低限制也会放行较旧的指纹。 -- **Mihomo 与 ML-KEM。** Xray-core v26.9.8+ 还独立要求 `X25519MLKEM768` key share 位于可选的 `X25519` 之前。Clash/Mihomo YAML 订阅会为 REALITY 节点(含外部链接)启用 `reality-opts.support-x25519mlkem768`,未设置指纹时使用 `chrome`。明确选择的指纹会保留,必须选择支持 ML-KEM 的指纹(Mihomo 使用 uTLS v1.8.7 时可选 `chrome`);开关无法让旧指纹获得新能力。原始 `vless://` 链接会携带等效的 `support-x25519mlkem768=true` 提示;支持此 URI 扩展的客户端(包括当前 Mihomo 版本)会在导入时应用它。对拒绝 ML-KEM 的很旧的 REALITY 服务端,需在客户端按节点将此项覆写为 `false`,或升级服务端。仅清空版本限制无法解决握手问题。 +- **Mihomo 与 ML-KEM。** Xray-core v26.9.8+ 还独立要求 `X25519MLKEM768` key share 位于可选的 `X25519` 之前。Clash/Mihomo YAML 订阅会为 REALITY 节点(含外部链接)启用 `reality-opts.support-x25519mlkem768`,未设置指纹时使用 `chrome`。明确选择的指纹会保留,必须选择支持 ML-KEM 的指纹(Mihomo 使用 uTLS v1.8.7 时可选 `chrome`);开关无法让旧指纹获得新能力。原始 `vless://` 链接会携带等效的 `support-x25519mlkem768=true` 提示;支持此 URI 扩展的客户端(包括当前 Mihomo 版本)会在导入时应用它。Xray-core 26.9.8 以下的服务端接受普通 `X25519` 握手,很旧的服务端则会拒绝 ML-KEM。对这些服务端,或网络会丢弃较大的 ML-KEM ClientHello 时,可在主机(Host)上启用 **Mihomo X25519**:该主机的 Clash 代理会得到 `support-x25519mlkem768: false`,`vless://` 链接也不再携带该提示。仅清空版本限制无法解决握手问题。 diff --git a/frontend/src/pages/hosts/HostFormModal.tsx b/frontend/src/pages/hosts/HostFormModal.tsx index 172e71160..f397ef77a 100644 --- a/frontend/src/pages/hosts/HostFormModal.tsx +++ b/frontend/src/pages/hosts/HostFormModal.tsx @@ -509,6 +509,7 @@ export default function HostFormModal({ diff --git a/internal/sub/clash_service.go b/internal/sub/clash_service.go index b6c2014a8..4790e4b5a 100644 --- a/internal/sub/clash_service.go +++ b/internal/sub/clash_service.go @@ -395,6 +395,13 @@ func (s *SubClashService) getProxies(subReq *SubService, inbound *model.Inbound, if v, _ := extPrxy["mihomoIpVersion"].(string); v != "" { proxy["ip-version"] = v } + // A pre-26.9.8 core accepts a plain X25519 hello, which some DPI lets + // through where it drops the two-segment ML-KEM one. + if hostX25519Only(extPrxy) { + if opts, ok := proxy["reality-opts"].(map[string]any); ok { + opts["support-x25519mlkem768"] = false + } + } proxies = append(proxies, proxy) } } diff --git a/internal/sub/host_sub.go b/internal/sub/host_sub.go index a8976a865..17c57dca0 100644 --- a/internal/sub/host_sub.go +++ b/internal/sub/host_sub.go @@ -98,6 +98,9 @@ func hostToExternalProxyMap(h *model.Host, defaultDest string, defaultPort int) if h.MihomoIpVersion != "" { ep["mihomoIpVersion"] = h.MihomoIpVersion } + if h.MihomoX25519 { + ep["mihomoX25519"] = true + } if h.SockoptParams != "" { ep["sockoptParams"] = h.SockoptParams } @@ -313,6 +316,16 @@ func applyEndpointRealityParams(e ShareEndpoint, params map[string]string, secur if fp, ok := e.ep["fingerprint"].(string); ok && fp != "" { params["fp"] = fp } + if hostX25519Only(e.ep) { + delete(params, "support-x25519mlkem768") + } +} + +// hostX25519Only reports whether a host asks Mihomo for a plain X25519 reality +// hello instead of the ML-KEM one Xray-core 26.9.8+ requires. +func hostX25519Only(ep map[string]any) bool { + v, _ := ep["mihomoX25519"].(bool) + return v } // applyEndpointAllowInsecure adds allowInsecure=1 to a TLS/Reality link when the diff --git a/internal/sub/host_sub_test.go b/internal/sub/host_sub_test.go index e26e1e85b..899bf70a4 100644 --- a/internal/sub/host_sub_test.go +++ b/internal/sub/host_sub_test.go @@ -8,6 +8,8 @@ import ( "strings" "testing" + "github.com/goccy/go-yaml" + "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/dbtest" "github.com/mhsanaei/3x-ui/v3/internal/database/model" @@ -548,6 +550,88 @@ func TestSub_HostRealityFingerprintJSONAndClash(t *testing.T) { } } +const mihomoRealityStream = `{"network":"tcp","security":"reality","tcpSettings":{"header":{"type":"none"}},"realitySettings":{"serverNames":["base.reality.com"],"shortIds":["abcd"],"settings":{"publicKey":"PBK","fingerprint":"chrome"}}}` + +// seedMihomoX25519Hosts puts a default host and a "Mihomo X25519" host on one +// reality inbound, so each test proves the toggle is per host, not per inbound. +func seedMihomoX25519Hosts(t *testing.T) { + t.Helper() + ib := seedSubInbound(t, "s1", "mx", 4493, 1, mihomoRealityStream) + seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 0, Remark: "PQ", Address: "pq.cdn.com", Port: 443}) + seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "CLASSIC", Address: "classic.cdn.com", Port: 443, MihomoX25519: true}) +} + +// Servers on Xray-core < 26.9.8 do not need ML-KEM, and a two-segment ML-KEM +// ClientHello is what some DPI drops; the host toggle must turn it off in Clash. +func TestSub_HostMihomoX25519Clash(t *testing.T) { + seedSubDB(t) + seedMihomoX25519Hosts(t) + + out, _, err := NewSubClashService(false, "", NewSubService("")).GetClash("s1", "req.example.com") + if err != nil { + t.Fatalf("GetClash: %v", err) + } + var config struct { + Proxies []struct { + Server string `yaml:"server"` + RealityOpts struct { + MLKEM *bool `yaml:"support-x25519mlkem768"` + } `yaml:"reality-opts"` + } `yaml:"proxies"` + } + if err := yaml.Unmarshal([]byte(out), &config); err != nil { + t.Fatalf("decode Clash YAML: %v\n%s", err, out) + } + want := map[string]bool{"pq.cdn.com": true, "classic.cdn.com": false} + for _, proxy := range config.Proxies { + mlkem, ok := want[proxy.Server] + if !ok { + continue + } + if got := proxy.RealityOpts.MLKEM; got == nil || *got != mlkem { + t.Errorf("%s: support-x25519mlkem768 = %s, want %v\n%s", proxy.Server, fmtOptBool(got), mlkem, out) + } + delete(want, proxy.Server) + } + if len(want) != 0 { + t.Fatalf("missing host proxies %v in:\n%s", want, out) + } +} + +func fmtOptBool(b *bool) string { + if b == nil { + return "absent" + } + return fmt.Sprint(*b) +} + +// Mihomo also reads the ML-KEM hint from imported vless:// links (#6712), so a +// "Mihomo X25519" host must not carry it there either. +func TestSub_HostMihomoX25519RawLink(t *testing.T) { + seedSubDB(t) + seedMihomoX25519Hosts(t) + + links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com") + if err != nil { + t.Fatalf("GetSubs: %v", err) + } + var pq, classic string + for _, link := range strings.Split(strings.Join(links, "\n"), "\n") { + switch { + case strings.Contains(link, "@pq.cdn.com:443"): + pq = link + case strings.Contains(link, "@classic.cdn.com:443"): + classic = link + } + } + if !strings.Contains(pq, "support-x25519mlkem768=true") { + t.Fatalf("default host must keep the ML-KEM hint: %q", pq) + } + if classic == "" || strings.Contains(classic, "support-x25519mlkem768") { + t.Fatalf("Mihomo X25519 host must drop the ML-KEM hint: %q", classic) + } +} + func proxyRealitySettings(t *testing.T, doc map[string]any) map[string]any { t.Helper() outbounds, _ := doc["outbounds"].([]any) diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index ccf5c6c28..4a8a74a85 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -2363,6 +2363,7 @@ "serverDescription": "ملاحظة اختيارية تظهر تحت الملاحظة.", "allowInsecure": "تخطّي التحقق من شهادة TLS (allowInsecure / skip-cert-verify).", "vlessRoute": "قيمة مسار VLESS واحدة (0-65535) تُدمَج في UUID، مثل 443. اتركه فارغاً لعدم وجود أي منها.", + "mihomoX25519": "Mihomo فقط: مصافحة REALITY باستخدام X25519 العادي بدون ML-KEM (support-x25519mlkem768: false). للخوادم التي تعمل بإصدار Xray-core أقدم من 26.9.8 ولا تتطلب ML-KEM.", "remark": "تسمية بسيطة لهذا المضيف. تظهر كاسم للإعداد فقط عندما لا يكون للوارد ملاحظة خاصة به." }, "remarkVars": { diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index 1e36a4f10..ddb19fb00 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -1112,6 +1112,7 @@ "serverDescription": "Optional note shown under the remark.", "allowInsecure": "Skip TLS certificate verification (allowInsecure / skip-cert-verify).", "vlessRoute": "Single VLESS route value (0-65535) baked into the UUID, e.g. 443. Leave blank for none.", + "mihomoX25519": "Mihomo only: use a plain X25519 REALITY hello without ML-KEM (support-x25519mlkem768: false). For servers on Xray-core older than 26.9.8, which do not require ML-KEM.", "remark": "A plain label for this host. Shown as the config name only when the inbound has no remark of its own." }, "remarkVars": { diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index a7ecf7081..d8e9b7cbd 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -2363,6 +2363,7 @@ "serverDescription": "Nota opcional que se muestra bajo las notas.", "allowInsecure": "Omitir la verificación del certificado TLS (allowInsecure / skip-cert-verify).", "vlessRoute": "Un único valor de ruta VLESS (0-65535) incrustado en el UUID, p. ej. 443. Déjalo en blanco para ninguno.", + "mihomoX25519": "Solo Mihomo: usa un saludo REALITY con X25519 simple, sin ML-KEM (support-x25519mlkem768: false). Para servidores con Xray-core anterior a 26.9.8, que no exigen ML-KEM.", "remark": "Una etiqueta simple para este host. Se muestra como nombre de la configuración solo cuando el inbound no tiene notas propias." }, "remarkVars": { diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index da42d9691..da8d9e9ea 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -2363,6 +2363,7 @@ "serverDescription": "یادداشت اختیاری که زیر نام نمایش داده می‌شود.", "allowInsecure": "رد کردن بررسی گواهی TLS (allowInsecure / skip-cert-verify).", "vlessRoute": "یک مقدار مسیر VLESS (0 تا 65535) که در UUID جاسازی می‌شود، مثلاً 443. برای هیچ‌کدام خالی بگذارید.", + "mihomoX25519": "فقط Mihomo: دست‌دهی REALITY با X25519 ساده و بدون ML-KEM (support-x25519mlkem768: false). برای سرورهایی با Xray-core قدیمی‌تر از 26.9.8 که به ML-KEM نیاز ندارند.", "remark": "یک برچسب ساده برای این میزبان. تنها زمانی به‌عنوان نام کانفیگ نمایش داده می‌شود که اینباند نام مخصوص خود را نداشته باشد." }, "remarkVars": { diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index 024054197..4ac3e6ca7 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -2363,6 +2363,7 @@ "serverDescription": "Catatan opsional yang ditampilkan di bawah catatan.", "allowInsecure": "Lewati verifikasi sertifikat TLS (allowInsecure / skip-cert-verify).", "vlessRoute": "Satu nilai rute VLESS (0-65535) yang disisipkan ke UUID, mis. 443. Biarkan kosong jika tidak ada.", + "mihomoX25519": "Hanya Mihomo: gunakan handshake REALITY dengan X25519 biasa tanpa ML-KEM (support-x25519mlkem768: false). Untuk server dengan Xray-core lebih lama dari 26.9.8 yang tidak mewajibkan ML-KEM.", "remark": "Label sederhana untuk host ini. Ditampilkan sebagai nama konfigurasi hanya ketika inbound tidak memiliki catatan tersendiri." }, "remarkVars": { diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index 2ad0a2f8c..c547c3637 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -2363,6 +2363,7 @@ "serverDescription": "備考の下に表示される任意のメモ。", "allowInsecure": "TLS 証明書の検証をスキップします(allowInsecure / skip-cert-verify)。", "vlessRoute": "UUID に埋め込まれる単一の VLESS ルート値(0〜65535)。例: 443。なしの場合は空欄にします。", + "mihomoX25519": "Mihomo のみ: ML-KEM を使わず通常の X25519 で REALITY ハンドシェイクを行います (support-x25519mlkem768: false)。ML-KEM を必要としない Xray-core 26.9.8 未満のサーバー向けです。", "remark": "このホストのプレーンなラベル。インバウンド自身に備考がない場合にのみ設定名として表示されます。" }, "remarkVars": { diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index e62c85309..9e8e996cd 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -2363,6 +2363,7 @@ "serverDescription": "Nota opcional exibida abaixo da observação.", "allowInsecure": "Ignorar a verificação do certificado TLS (allowInsecure / skip-cert-verify).", "vlessRoute": "Um único valor de rota VLESS (0-65535) embutido no UUID, ex.: 443. Deixe em branco para nenhum.", + "mihomoX25519": "Somente Mihomo: usa um handshake REALITY com X25519 simples, sem ML-KEM (support-x25519mlkem768: false). Para servidores com Xray-core anterior à 26.9.8, que não exigem ML-KEM.", "remark": "Um rótulo simples para este host. Mostrado como nome da configuração apenas quando a entrada não tem observação própria." }, "remarkVars": { diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index b8b4029bc..ea658a88a 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -2363,6 +2363,7 @@ "serverDescription": "Необязательная заметка, отображаемая под примечанием.", "allowInsecure": "Пропустить проверку TLS-сертификата (allowInsecure / skip-cert-verify).", "vlessRoute": "Одно значение маршрута VLESS (0-65535), встраиваемое в UUID, напр. 443. Оставьте пустым, чтобы отключить.", + "mihomoX25519": "Только для Mihomo: REALITY-рукопожатие с обычным X25519, без ML-KEM (support-x25519mlkem768: false). Для серверов на Xray-core ниже 26.9.8, которым ML-KEM не нужен.", "remark": "Обычная метка для этого хоста. Используется как имя конфигурации, только если у входящего нет собственного примечания." }, "remarkVars": { diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 1d487bcb7..0ade376aa 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -2363,6 +2363,7 @@ "serverDescription": "Açıklamanın altında gösterilen isteğe bağlı not.", "allowInsecure": "TLS sertifika doğrulamasını atla (allowInsecure / skip-cert-verify).", "vlessRoute": "UUID'ye gömülen tek bir VLESS rota değeri (0-65535), örn. 443. Hiçbiri için boş bırakın.", + "mihomoX25519": "Yalnızca Mihomo: ML-KEM olmadan düz X25519 ile REALITY el sıkışması kullanır (support-x25519mlkem768: false). ML-KEM gerektirmeyen, 26.9.8'den eski Xray-core sunucuları için.", "remark": "Bu host için düz bir etiket. Yalnızca gelen bağlantının kendi açıklaması yoksa yapılandırma adı olarak gösterilir." }, "remarkVars": { diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index 991e112b5..8839fb82f 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -2363,6 +2363,7 @@ "serverDescription": "Необов'язкова примітка, що показується під приміткою.", "allowInsecure": "Пропускати перевірку TLS-сертифіката (allowInsecure / skip-cert-verify).", "vlessRoute": "Одне значення маршруту VLESS (0-65535), що вбудовується в UUID, напр. 443. Залиште порожнім, щоб не використовувати.", + "mihomoX25519": "Лише для Mihomo: рукостискання REALITY зі звичайним X25519, без ML-KEM (support-x25519mlkem768: false). Для серверів на Xray-core нижче 26.9.8, яким ML-KEM не потрібен.", "remark": "Звичайна мітка для цього хоста. Показується як назва конфігурації лише тоді, коли вхідний не має власної примітки." }, "remarkVars": { diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 6df8d3ac7..3c8cc213c 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -2363,6 +2363,7 @@ "serverDescription": "Ghi chú tùy chọn hiển thị bên dưới ghi chú.", "allowInsecure": "Bỏ qua xác minh chứng chỉ TLS (allowInsecure / skip-cert-verify).", "vlessRoute": "Một giá trị định tuyến VLESS (0-65535) được nhúng vào UUID, ví dụ 443. Để trống nếu không dùng.", + "mihomoX25519": "Chỉ Mihomo: dùng bắt tay REALITY với X25519 thường, không có ML-KEM (support-x25519mlkem768: false). Dành cho máy chủ chạy Xray-core cũ hơn 26.9.8, không yêu cầu ML-KEM.", "remark": "Nhãn đơn giản cho host này. Chỉ hiển thị làm tên cấu hình khi inbound không có ghi chú riêng." }, "remarkVars": { diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index a083b48bb..76b537061 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -2363,6 +2363,7 @@ "serverDescription": "可选备注,显示在备注下方。", "allowInsecure": "跳过 TLS 证书验证(allowInsecure / skip-cert-verify)。", "vlessRoute": "嵌入 UUID 的单个 VLESS 路由值(0-65535),例如 443。留空表示不路由。", + "mihomoX25519": "仅 Mihomo:REALITY 握手只使用普通 X25519,不带 ML-KEM(support-x25519mlkem768: false)。适用于运行 Xray-core 26.9.8 以下、不要求 ML-KEM 的服务器。", "remark": "此主机的纯文本标签。仅当入站没有自己的备注时,才作为配置名称显示。" }, "remarkVars": { diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 7a22cbca5..b6635d239 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -2363,6 +2363,7 @@ "serverDescription": "顯示於備註下方的選填註記。", "allowInsecure": "略過 TLS 憑證驗證(allowInsecure / skip-cert-verify)。", "vlessRoute": "嵌入 UUID 的單一 VLESS 路由值(0-65535),例如 443。留空表示無。", + "mihomoX25519": "僅 Mihomo:REALITY 握手只使用一般 X25519,不帶 ML-KEM(support-x25519mlkem768: false)。適用於執行 Xray-core 26.9.8 以下、不要求 ML-KEM 的伺服器。", "remark": "此 Host 的純文字標籤。僅當入站本身沒有備註時,才作為配置名稱顯示。" }, "remarkVars": { From 0aaba17d59e9f7b4e6f1a69ffbd8972999b61b5c Mon Sep 17 00:00:00 2001 From: Toximiner Date: Mon, 12 Oct 2026 22:43:23 +0300 Subject: [PATCH 3/4] fix(sub): give every host endpoint its own deep copy of the stream cloneStreamForExternalProxy deep-copied only tlsSettings, so the hosts of one inbound shared realitySettings and wsSettings/xhttpSettings within a request. applyHostStreamOverrides writes SNI, fingerprint, Host and path into those maps in place, so a host that leaves one blank inherited the previous host's value in Clash and JSON while its vless:// link kept the inbound's. Deep-copy the whole stream per endpoint. --- internal/sub/host_sub_test.go | 108 ++++++++++++++++++++++++++++++++++ internal/sub/service.go | 42 ++++++++----- 2 files changed, 136 insertions(+), 14 deletions(-) diff --git a/internal/sub/host_sub_test.go b/internal/sub/host_sub_test.go index 899bf70a4..8d0e7e0ad 100644 --- a/internal/sub/host_sub_test.go +++ b/internal/sub/host_sub_test.go @@ -550,6 +550,114 @@ func TestSub_HostRealityFingerprintJSONAndClash(t *testing.T) { } } +// Hosts of one inbound render from per-host copies of the same stream; a host +// that leaves an override blank must get the inbound's value, not the previous host's. +func TestSub_HostBlankOverridesDoNotLeakBetweenHosts(t *testing.T) { + for _, tc := range []struct { + name string + stream string + first model.Host + clashWant map[string]string + jsonField func(stream map[string]any) any + jsonWant any + }{ + { + name: "reality fingerprint and sni", + stream: mihomoRealityStream, + first: model.Host{Security: "reality", Sni: "a.reality.com", Fingerprint: "firefox"}, + clashWant: map[string]string{"client-fingerprint": "chrome", "servername": "base.reality.com"}, + jsonField: func(stream map[string]any) any { + rs, _ := stream["realitySettings"].(map[string]any) + return rs["fingerprint"] + }, + jsonWant: "chrome", + }, + { + name: "ws host header and path", + stream: wsTLSStream, + first: model.Host{Security: "tls", HostHeader: "a.host", Path: "/a"}, + clashWant: map[string]string{"ws-opts.path": "/base", "ws-opts.headers.Host": "base.host"}, + jsonField: func(stream map[string]any) any { + ws, _ := stream["wsSettings"].(map[string]any) + return ws["path"] + }, + jsonWant: "/base", + }, + } { + t.Run(tc.name, func(t *testing.T) { + seedSubDB(t) + ib := seedSubInbound(t, "s1", "lk", 4494, 1, tc.stream) + first := tc.first + first.InboundId, first.SortOrder, first.Remark, first.Address, first.Port = ib.Id, 0, "A", "a.cdn.com", 443 + seedHost(t, &first) + seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "B", Address: "b.cdn.com", Port: 443, Security: tc.first.Security}) + + out, _, err := NewSubClashService(false, "", NewSubService("")).GetClash("s1", "req.example.com") + if err != nil { + t.Fatalf("GetClash: %v", err) + } + second := clashProxyByServer(t, out, "b.cdn.com") + for field, want := range tc.clashWant { + if got := lookupPath(second, field); got != want { + t.Errorf("host B clash %s = %v, want the inbound's %q", field, got, want) + } + } + + out, _, err = NewSubJsonService("", "", "", "", NewSubService("")).GetJson("s1", "req.example.com", false) + if err != nil { + t.Fatalf("GetJson: %v", err) + } + var configs []map[string]any + if err := json.Unmarshal([]byte(out), &configs); err != nil || len(configs) != 2 { + t.Fatalf("want one JSON config per host, got err=%v:\n%s", err, out) + } + if got := tc.jsonField(proxyStreamSettings(t, configs[1])); got != tc.jsonWant { + t.Errorf("host B json value = %v, want the inbound's %v", got, tc.jsonWant) + } + }) + } +} + +func clashProxyByServer(t *testing.T, out, server string) map[string]any { + t.Helper() + var config struct { + Proxies []map[string]any `yaml:"proxies"` + } + if err := yaml.Unmarshal([]byte(out), &config); err != nil { + t.Fatalf("decode Clash YAML: %v\n%s", err, out) + } + for _, proxy := range config.Proxies { + if proxy["server"] == server { + return proxy + } + } + t.Fatalf("no clash proxy for %s in:\n%s", server, out) + return nil +} + +func lookupPath(m map[string]any, path string) any { + var cur any = m + for _, key := range strings.Split(path, ".") { + next, _ := cur.(map[string]any) + cur = next[key] + } + return cur +} + +func proxyStreamSettings(t *testing.T, doc map[string]any) map[string]any { + t.Helper() + outbounds, _ := doc["outbounds"].([]any) + for _, ob := range outbounds { + outbound, _ := ob.(map[string]any) + if outbound["tag"] == "proxy" { + stream, _ := outbound["streamSettings"].(map[string]any) + return stream + } + } + t.Fatalf("no proxy outbound in %v", doc) + return nil +} + const mihomoRealityStream = `{"network":"tcp","security":"reality","tcpSettings":{"header":{"type":"none"}},"realitySettings":{"serverNames":["base.reality.com"],"shortIds":["abcd"],"settings":{"publicKey":"PBK","fingerprint":"chrome"}}}` // seedMihomoX25519Hosts puts a default host and a "Mihomo X25519" host on one diff --git a/internal/sub/service.go b/internal/sub/service.go index 48fbacc1e..fadfe5179 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -2118,24 +2118,38 @@ func applyExternalProxyHysteriaParams(ep map[string]any, params map[string]strin } } -// cloneStreamForExternalProxy returns a shallow clone of stream with -// tlsSettings (and its nested settings map) deep-copied. The external -// proxy loop mutates tlsSettings per iteration, so without isolating -// those maps each proxy's SNI/fingerprint/ALPN would leak into the next. +// cloneStreamForExternalProxy deep-copies stream: every endpoint overrides nested +// maps in place, so a shared map leaks one host's SNI/fp/host/path into the next. func cloneStreamForExternalProxy(stream map[string]any) map[string]any { - out := cloneMap(stream) - ts, ok := out["tlsSettings"].(map[string]any) - if !ok || ts == nil { - return out - } - clonedTs := cloneMap(ts) - if inner, ok := clonedTs["settings"].(map[string]any); ok && inner != nil { - clonedTs["settings"] = cloneMap(inner) - } - out["tlsSettings"] = clonedTs + out, _ := deepCopyJSONValue(stream).(map[string]any) return out } +func deepCopyJSONValue(v any) any { + switch val := v.(type) { + case map[string]any: + if val == nil { + return val + } + out := make(map[string]any, len(val)) + for k, item := range val { + out[k] = deepCopyJSONValue(item) + } + return out + case []any: + if val == nil { + return val + } + out := make([]any, len(val)) + for i, item := range val { + out[i] = deepCopyJSONValue(item) + } + return out + default: + return v + } +} + func applyExternalProxyTLSToStream(ep map[string]any, stream map[string]any, security string) { if security != "tls" { return From 143a4d98635f212328e900610de1453cf1d08328 Mon Sep 17 00:00:00 2001 From: Toximiner Date: Mon, 12 Oct 2026 22:44:26 +0300 Subject: [PATCH 4/4] feat(database): clear Mihomo X25519 switches saved while they were ignored Before the switch started dropping ML-KEM it was stored but had no effect, so an operator could have turned it on without noticing. Those rows would silently lose ML-KEM on upgrade and break Mihomo clients of a 26.9.8+ server. Reset them once, recorded in history_of_seeders, so every subscription renders exactly as before; switches set after the upgrade stay. --- internal/database/db.go | 19 ++++++++++- internal/database/host_migration_test.go | 42 ++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/internal/database/db.go b/internal/database/db.go index bd202f508..1c445cf32 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -431,6 +431,17 @@ func migrateHostVerifyPeerCertByNameColumn() error { return db.Exec(`UPDATE hosts SET verify_peer_cert_by_name = '' WHERE verify_peer_cert_by_name IS NULL OR typeof(verify_peer_cert_by_name) <> 'text'`).Error } +// mihomo_x25519 was saved but ignored until it began dropping ML-KEM, so rows set +// back then are cleared once rather than silently changing their subscriptions. +func resetPreexistingMihomoX25519() error { + return db.Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&model.Host{}).Where("mihomo_x25519 = ?", true).Update("mihomo_x25519", false).Error; err != nil { + return err + } + return tx.Create(&model.HistoryOfSeeders{SeederName: "HostMihomoX25519Reset"}).Error + }) +} + // AutoMigrate casts the boolean-era column to text before the migration above runs, // so a saved false/true reads back as the cert name "0"/"false"/"1"/"true". func blankLegacyBoolVerifyPeerCertByName() error { @@ -1362,7 +1373,7 @@ func runSeeders(isUsersEmpty bool) error { } if empty && isUsersEmpty { - seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "UppercaseFreedomFinalRulesFix", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "OutboundRemovedKeysFix", "FreedomDomainStrategyFix", "DNSOutboundLegacyKeysFix", "DNSOutboundQTypeZeroFix", "WireguardDomainStrategyFix", "XdnsFinalmaskNamesAddrsFix", "UDPHopClientMaskFix", "HostVerifyPeerCertByNameBoolFix", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "ResetIpLimitNoFail2ban"} + seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "UppercaseFreedomFinalRulesFix", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "OutboundRemovedKeysFix", "FreedomDomainStrategyFix", "DNSOutboundLegacyKeysFix", "DNSOutboundQTypeZeroFix", "WireguardDomainStrategyFix", "XdnsFinalmaskNamesAddrsFix", "UDPHopClientMaskFix", "HostVerifyPeerCertByNameBoolFix", "HostMihomoX25519Reset", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "ResetIpLimitNoFail2ban"} for _, name := range seeders { if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil { return err @@ -1521,6 +1532,12 @@ func runSeeders(isUsersEmpty bool) error { } } + if !slices.Contains(seedersHistory, "HostMihomoX25519Reset") { + if err := resetPreexistingMihomoX25519(); err != nil { + return err + } + } + if !slices.Contains(seedersHistory, "NodeInboundsAdopted") { if err := seedNodeInboundsAdopted(); err != nil { return err diff --git a/internal/database/host_migration_test.go b/internal/database/host_migration_test.go index 63f30d57e..87cc012bb 100644 --- a/internal/database/host_migration_test.go +++ b/internal/database/host_migration_test.go @@ -459,3 +459,45 @@ func TestMigrateMtprotoCustomShareAddrWithUnusableSameAddressHost(t *testing.T) }) } } + +// Mihomo X25519 was stored but ignored before it started dropping ML-KEM; a row +// switched on back then must not change its subscription output on upgrade. +func TestMigrate_PreexistingMihomoX25519IsResetOnce(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "x-ui.db") + if err := InitDB(dbPath); err != nil { + t.Fatalf("InitDB: %v", err) + } + t.Cleanup(func() { _ = CloseDB() }) + ib := seedInboundWithStream(t, "mx-legacy", 5559, `{"network":"tcp","security":"reality"}`) + host := &model.Host{InboundId: ib.Id, Remark: "legacy", Address: "cdn.example.com", MihomoX25519: true} + if err := GetDB().Create(host).Error; err != nil { + t.Fatalf("seed host: %v", err) + } + if err := GetDB().Where("seeder_name = ?", "HostMihomoX25519Reset").Delete(&model.HistoryOfSeeders{}).Error; err != nil { + t.Fatalf("clear seeder history: %v", err) + } + reopen := func() model.Host { + t.Helper() + if err := CloseDB(); err != nil { + t.Fatalf("CloseDB: %v", err) + } + if err := InitDB(dbPath); err != nil { + t.Fatalf("reopen InitDB: %v", err) + } + var got model.Host + if err := GetDB().First(&got, host.Id).Error; err != nil { + t.Fatalf("load host: %v", err) + } + return got + } + + if got := reopen(); got.MihomoX25519 { + t.Fatalf("pre-existing mihomoX25519 = true after upgrade, want it reset to false") + } + if err := GetDB().Model(&model.Host{}).Where("id = ?", host.Id).Update("mihomo_x25519", true).Error; err != nil { + t.Fatalf("switch on after upgrade: %v", err) + } + if got := reopen(); !got.MihomoX25519 { + t.Fatalf("mihomoX25519 set after the upgrade was reset again on restart") + } +}