Ver código fonte

fix(sub): carry REALITY ML-KEM hint in VLESS links (#6712)

* fix(sub): carry REALITY ML-KEM hint in VLESS links

Keep raw share links in parity with Clash subscriptions for Xray 26.9.8+. Preserve the URI hint through Go and frontend imports, expose it in the outbound editor, and update the documentation tooling.

* fix(link): accept REALITY ML-KEM boolean aliases

* test(frontend): isolate Happ preset notifications

* fix(link): keep the ML-KEM hint out of Xray REALITY settings

support-x25519mlkem768 is a Mihomo reality-opts option; xray-core's
REALITYConfig (infra/conf/transport_security.go) has no such field and its
JSON loader drops unknown keys silently. The PR also stored it as
realitySettings.supportX25519Mlkem768 in Xray outbounds (form switch, Go and
TS link import, docs outbound builders) and as an inbound settings default
that is stripped before Xray and read by no link generator. The outbound
switch therefore did nothing, and imported links carried a dead key into the
JSON subscription.

The share-link hint itself stays: Go, frontend and docs still emit
support-x25519mlkem768=true on VLESS REALITY links and drop it on a TLS host
override.

---------

Co-authored-by: libmur-dev <[email protected]>
Co-authored-by: MHSanaei <[email protected]>
libmur-dev 11 horas atrás
pai
commit
ce221c33d0

+ 1 - 0
docs/components/tools/reality-config-generator.tsx

@@ -66,6 +66,7 @@ export function RealityConfigGenerator() {
           fingerprint,
           spiderX: '/',
           flow: 'xtls-rprx-vision',
+          supportX25519Mlkem768: true,
         }
       : null;
 

+ 5 - 4
docs/content/docs/en/config/reality.mdx

@@ -129,10 +129,11 @@ vless://<uuid>@<server>:443?security=reality&pbk=<public-key>&sid=<short-id>&sni
   nodes, including external links, and uses `chrome` when no fingerprint was set.
   Explicit fingerprints are preserved: choose one that offers ML-KEM (`chrome`
   with Mihomo's uTLS v1.8.7); enabling the flag cannot upgrade an old fingerprint.
-  Raw `vless://` links do not carry this Mihomo option, so clients importing them
-  directly still need a persistent override. 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.
+  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.
 
 </Callout>
 

+ 3 - 1
docs/content/docs/fa/config/reality.mdx

@@ -137,7 +137,9 @@ vless://<uuid>@<server>:443?security=reality&pbk=<public-key>&sid=<short-id>&sni
   در Mihomo از Chrome استفاده کنید. فعال کردن گزینه، اثر انگشت قدیمی را ارتقا نمی‌دهد.
   لینک خام
   `vless://`
-  این گزینه را منتقل نمی‌کند و هنگام ورود مستقیم، بازنویسی پایدار در کلاینت لازم است.
+  راهنمای معادل
+  `support-x25519mlkem768=true`
+  را منتقل می‌کند؛ کلاینت‌هایی که این افزونهٔ URI را پشتیبانی می‌کنند، از جمله نسخه‌های فعلی Mihomo، آن را هنگام ورود اعمال می‌کنند.
   برای سرورهای بسیار قدیمی که ML-KEM را رد می‌کنند، گزینه را برای همان گره در کلاینت روی
   `false`
   بگذارید یا سرور را ارتقا دهید. حذف محدودیت نسخه به‌تنهایی دست‌دهی را اصلاح نمی‌کند.

+ 6 - 5
docs/content/docs/ru/config/reality.mdx

@@ -133,11 +133,12 @@ vless://<uuid>@<server>:443?security=reality&pbk=<public-key>&sid=<short-id>&sni
   включает `reality-opts.support-x25519mlkem768` для REALITY, в том числе внешних
   ссылок, и выбирает `chrome`, если отпечаток не задан. Явный выбор сохраняется:
   нужен отпечаток с ML-KEM (`chrome` при uTLS v1.8.7 в Mihomo). Сам флаг не
-  обновляет старые отпечатки. Исходные ссылки `vless://` не передают эту настройку
-  Mihomo; при прямом импорте нужно постоянное переопределение в клиенте. Для очень
-  старых серверов REALITY, отвергающих ML-KEM, задайте `false` для соответствующего
-  узла в клиенте или обновите сервер. Снятие ограничения версии не исправляет
-  это рукопожатие.
+  обновляет старые отпечатки. Исходные ссылки `vless://` передают эквивалентную
+  подсказку `support-x25519mlkem768=true`; клиенты, поддерживающие это расширение
+  URI, включая актуальные сборки Mihomo, применяют её при импорте. Для очень старых
+  серверов REALITY, отвергающих ML-KEM, задайте `false` для соответствующего узла
+  в клиенте или обновите сервер. Снятие ограничения версии не исправляет это
+  рукопожатие.
 
 </Callout>
 

+ 1 - 1
docs/content/docs/zh/config/reality.mdx

@@ -106,7 +106,7 @@ vless://<uuid>@<server>:443?security=reality&pbk=<public-key>&sid=<short-id>&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://` 链接不携带这个 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 版本)会在导入时应用它。对拒绝 ML-KEM 的很旧的 REALITY 服务端,需在客户端按节点将此项覆写为 `false`,或升级服务端。仅清空版本限制无法解决握手问题。
 
 </Callout>
 

+ 2 - 0
docs/lib/xray/reality.test.ts

@@ -50,6 +50,7 @@ const CONFIG: RealityConfig = {
   fingerprint: 'chrome',
   spiderX: '/',
   flow: 'xtls-rprx-vision',
+  supportX25519Mlkem768: true,
 };
 
 describe('realityClientLink', () => {
@@ -61,6 +62,7 @@ describe('realityClientLink', () => {
     expect(parsed.port).toBe(443);
     expect(parsed.params.security).toBe('reality');
     expect(parsed.params.pbk).toBe('PUB');
+    expect(parsed.params['support-x25519mlkem768']).toBe('true');
     expect(parsed.params.sid).toBe('ab12');
     expect(parsed.params.sni).toBe('www.microsoft.com');
     expect(parsed.params.flow).toBe('xtls-rprx-vision');

+ 2 - 0
docs/lib/xray/reality.ts

@@ -64,6 +64,7 @@ export interface RealityConfig {
   fingerprint: string;
   spiderX: string;
   flow: string;
+  supportX25519Mlkem768: boolean;
 }
 
 /** Server-side VLESS + REALITY inbound (Xray config shape). */
@@ -108,6 +109,7 @@ export function realityClientLink(c: RealityConfig): string {
       sid: c.shortIds[0] ?? '',
       spx: c.spiderX,
       flow: c.flow,
+      ...(c.supportX25519Mlkem768 ? { 'support-x25519mlkem768': 'true' } : {}),
     },
     name: `${c.address}-reality`,
   });

+ 9 - 0
docs/lib/xray/subscription.test.ts

@@ -74,6 +74,7 @@ describe('buildShareLinks', () => {
     expect(parsed.port).toBe(443);
     expect(parsed.credential).toBe('11111111-2222-3333-4444-555555555555');
     expect(parsed.params.security).toBe('reality');
+    expect(parsed.params['support-x25519mlkem768']).toBe('true');
     expect(parsed.name).toBe('HK-01');
   });
 });
@@ -120,6 +121,14 @@ describe('buildJsonSubscription', () => {
     expect(cfg.remarks).toBe('HK-01');
   });
 
+  it('keeps the Mihomo-only ML-KEM hint out of the Xray realitySettings', () => {
+    const cfg = JSON.parse(buildJsonSubscription([vlessClient]));
+    expect(cfg.outbounds[0].streamSettings.realitySettings.publicKey).toBe(vlessClient.publicKey);
+    expect(cfg.outbounds[0].streamSettings.realitySettings).not.toHaveProperty(
+      'supportX25519Mlkem768',
+    );
+  });
+
   it('uses the iOS-compatible SOCKS inbound while preserving the mixed tag and HTTP inbound', () => {
     const cfg = JSON.parse(buildJsonSubscription([vlessClient]));
     const socks = cfg.inbounds.find((inbound: { port: number }) => inbound.port === 10808);

+ 4 - 0
docs/lib/xray/subscription.ts

@@ -47,6 +47,7 @@ export interface SubClient {
   serviceName?: string;
   publicKey?: string; // reality
   shortId?: string; // reality
+  supportX25519Mlkem768?: boolean; // reality client compatibility
 }
 
 function normPath(p: string): string {
@@ -77,6 +78,9 @@ function streamParams(c: SubClient): Record<string, string> {
   if (c.serviceName) p.serviceName = c.serviceName;
   if (c.publicKey) p.pbk = c.publicKey;
   if (c.shortId) p.sid = c.shortId;
+  if (c.security === 'reality' && c.publicKey && c.supportX25519Mlkem768 !== false) {
+    p['support-x25519mlkem768'] = 'true';
+  }
   return p;
 }
 

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

@@ -453,6 +453,7 @@ export function genVlessLink(input: GenVlessLinkInput): string {
     applyExternalProxyTLSParams(externalProxy, params, security);
   } else if (security === 'reality') {
     params.set('security', 'reality');
+    params.set('support-x25519mlkem768', 'true');
     if (stream.security === 'reality') {
       const reality = stream.realitySettings;
       params.set('pbk', reality.settings.publicKey);

+ 2 - 2
frontend/src/test/__snapshots__/inbound-link.test.ts.snap

@@ -8,7 +8,7 @@ exports[`genInboundLinks orchestrator > shadowsocks-tcp-2022: byte-stable 1`] =
 
 exports[`genInboundLinks orchestrator > trojan-ws-tls: byte-stable 1`] = `"trojan://[email protected]:443?type=ws&path=%2Ftrojan&host=trojan.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=trojan.example.test#parity-test"`;
 
-exports[`genInboundLinks orchestrator > vless-tcp-reality: byte-stable 1`] = `"vless://[email protected]:443?type=tcp&encryption=none&security=reality&pbk=Tx5yj1bRcOPHkdvT2pIAQ2zh0gQ8m4OPdnzqXJxxV3o&fp=chrome&sni=yahoo.com&sid=a3f1&spx=%2Fdafd018f50a389b&flow=xtls-rprx-vision#parity-test"`;
+exports[`genInboundLinks orchestrator > vless-tcp-reality: byte-stable 1`] = `"vless://[email protected]:443?type=tcp&encryption=none&security=reality&support-x25519mlkem768=true&pbk=Tx5yj1bRcOPHkdvT2pIAQ2zh0gQ8m4OPdnzqXJxxV3o&fp=chrome&sni=yahoo.com&sid=a3f1&spx=%2Fdafd018f50a389b&flow=xtls-rprx-vision#parity-test"`;
 
 exports[`genInboundLinks orchestrator > vless-ws-tls: byte-stable 1`] = `"vless://[email protected]:443?type=ws&encryption=none&path=%2Fws&host=cdn.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=cdn.example.test#parity-test"`;
 
@@ -36,7 +36,7 @@ exports[`genShadowsocksLink > shadowsocks-tcp-2022: byte-stable 1`] = `"ss://202
 
 exports[`genTrojanLink > trojan-ws-tls: byte-stable 1`] = `"trojan://[email protected]:443?type=ws&path=%2Ftrojan&host=trojan.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=trojan.example.test#parity-test"`;
 
-exports[`genVlessLink > vless-tcp-reality: byte-stable 1`] = `"vless://[email protected]:443?type=tcp&encryption=none&security=reality&pbk=Tx5yj1bRcOPHkdvT2pIAQ2zh0gQ8m4OPdnzqXJxxV3o&fp=chrome&sni=yahoo.com&sid=a3f1&spx=%2Fd08ed99bd9afc60&flow=xtls-rprx-vision#parity-test"`;
+exports[`genVlessLink > vless-tcp-reality: byte-stable 1`] = `"vless://[email protected]:443?type=tcp&encryption=none&security=reality&support-x25519mlkem768=true&pbk=Tx5yj1bRcOPHkdvT2pIAQ2zh0gQ8m4OPdnzqXJxxV3o&fp=chrome&sni=yahoo.com&sid=a3f1&spx=%2Fd08ed99bd9afc60&flow=xtls-rprx-vision#parity-test"`;
 
 exports[`genVlessLink > vless-ws-tls: byte-stable 1`] = `"vless://[email protected]:443?type=ws&encryption=none&path=%2Fws&host=cdn.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=cdn.example.test#parity-test"`;
 

+ 3 - 0
frontend/src/test/happ-settings-presets.test.tsx

@@ -1,12 +1,15 @@
 import { useState } from 'react';
 import { describe, expect, it, vi } from 'vitest';
 import { fireEvent, screen } from '@testing-library/react';
+import { message } from 'antd';
 
 import { AllSetting } from '@/models/setting';
 import HappSettingsContent from '@/pages/settings/HappSettingsContent';
 
 import { renderWithProviders } from './test-utils';
 
+vi.spyOn(message, 'success').mockImplementation(() => undefined as never);
+
 const chinaProfile = {
   Name: 'Bypass-CN',
   GlobalProxy: 'true',

+ 16 - 0
frontend/src/test/inbound-link.test.ts

@@ -86,6 +86,22 @@ describe('genVlessLink', () => {
   const fixtures = fixturesForProtocol('vless');
   expect(fixtures.length, 'need at least one vless full-inbound fixture').toBeGreaterThan(0);
 
+  it('enables X25519MLKEM768 in REALITY share links', () => {
+    const entry = fixtures.find(([name]) => name === 'vless-tcp-reality');
+    expect(entry, 'need a VLESS REALITY fixture').toBeDefined();
+    const [, raw] = entry!;
+    const typed = InboundSchema.parse(raw);
+    const client = (raw as { settings: { clients: Array<{ id: string }> } }).settings.clients[0];
+
+    const link = genVlessLink({
+      inbound: typed,
+      address: 'example.test',
+      clientId: client.id,
+    });
+
+    expect(new URL(link).searchParams.get('support-x25519mlkem768')).toBe('true');
+  });
+
   for (const [name, raw] of fixtures) {
     it(`${name}: byte-stable`, () => {
       const typed = InboundSchema.parse(raw);

+ 10 - 0
frontend/src/test/outbound-link-parser.test.ts

@@ -9,6 +9,7 @@ import {
   parseHysteria2Link,
   parseWireguardLink,
 } from '@/lib/xray/outbound-link-parser';
+import { formValuesToWirePayload, rawOutboundToFormValues } from '@/lib/xray/outbound-form-adapter';
 import { Base64 } from '@/utils';
 
 // Focused acceptance tests for the share-link parsers — one happy-path
@@ -248,6 +249,7 @@ describe('parseVlessLink', () => {
     const link =
       'vless://[email protected]:443' +
       '?type=tcp&security=reality&pbk=pubkey&sid=abcd&fp=chrome&sni=cloudflare.com&flow=xtls-rprx-vision' +
+      '&support-x25519mlkem768=true' +
       '#imported-vless';
     const out = parseVlessLink(link);
     expect(out?.protocol).toBe('vless');
@@ -263,6 +265,14 @@ describe('parseVlessLink', () => {
     expect(reality.publicKey).toBe('pubkey');
     expect(reality.shortId).toBe('abcd');
     expect(reality.serverName).toBe('cloudflare.com');
+    // The hint is for Mihomo; xray-core's REALITYConfig has no such field.
+    expect(reality).not.toHaveProperty('supportX25519Mlkem768');
+
+    const form = rawOutboundToFormValues(out!);
+    const saved = formValuesToWirePayload(form);
+    const savedReality = (saved.streamSettings as Record<string, unknown>)
+      .realitySettings as Record<string, unknown>;
+    expect(savedReality).not.toHaveProperty('supportX25519Mlkem768');
   });
 
   it('parses encryption + pqv (post-quantum) into settings and mldsa65Verify', () => {

+ 9 - 1
internal/sub/endpoint.go

@@ -98,7 +98,15 @@ func dropBaseRealityParams(params map[string]string, baseSecurity, securityToApp
 	}
 	// sni and fp name the master's reality dest, not this endpoint's own
 	// certificate; the host's values are re-applied right after this.
-	for _, k := range []string{"pbk", "sid", "spx", "pqv", "sni", "fp"} {
+	for _, k := range []string{
+		"pbk",
+		"sid",
+		"spx",
+		"pqv",
+		"support-x25519mlkem768",
+		"sni",
+		"fp",
+	} {
 		delete(params, k)
 	}
 }

+ 7 - 1
internal/sub/host_sub_test.go

@@ -434,7 +434,13 @@ func TestSub_HostTlsOverRealityDropsRealityParams(t *testing.T) {
 	if !strings.Contains(joined, "security=tls") {
 		t.Fatalf("host forces tls, link must say so: %s", joined)
 	}
-	for _, leaked := range []string{"pbk=", "sid=", "spx=", "sni=master-dest.example.com"} {
+	for _, leaked := range []string{
+		"pbk=",
+		"sid=",
+		"spx=",
+		"support-x25519mlkem768=",
+		"sni=master-dest.example.com",
+	} {
 		if strings.Contains(joined, leaked) {
 			t.Fatalf("reality parameter %q survived a tls host override: %s", leaked, joined)
 		}

+ 1 - 0
internal/sub/service.go

@@ -1240,6 +1240,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
 		applyShareTLSParams(stream, params)
 	case "reality":
 		applyShareRealityParams(stream, params, subKey(client))
+		params["support-x25519mlkem768"] = "true"
 	default:
 		params["security"] = "none"
 	}

+ 1 - 0
internal/sub/service_sharelink_test.go

@@ -70,6 +70,7 @@ func TestGenVlessLink_RealityParamsMapped(t *testing.T) {
 
 	wants := []string{
 		"security=reality",
+		"support-x25519mlkem768=true",
 		"sni=reality.example.com",
 		"pbk=PBKvalue",
 		"sid=ab12cd",

+ 23 - 1
internal/util/link/outbound_helpers_test.go

@@ -1,11 +1,15 @@
 package link
 
 import (
+	"bytes"
 	"encoding/base64"
+	"encoding/json"
 	"net/url"
 	"reflect"
 	"slices"
 	"testing"
+
+	"github.com/xtls/xray-core/infra/conf"
 )
 
 func TestDefaultPort(t *testing.T) {
@@ -111,7 +115,7 @@ func streamSub(t *testing.T, res *ParseResult, key string) map[string]any {
 }
 
 func TestParse_RealitySecurityMapped(t *testing.T) {
-	res, err := ParseLink("vless://[email protected]:443?type=tcp&security=reality&pbk=PBK&sid=SID&sni=SNI&fp=firefox&spx=%2Fspx&pqv=PQV")
+	res, err := ParseLink("vless://[email protected]:443?type=tcp&security=reality&pbk=PBK&sid=SID&sni=SNI&fp=firefox&spx=%2Fspx&pqv=PQV&support-x25519mlkem768=true")
 	if err != nil {
 		t.Fatalf("parse: %v", err)
 	}
@@ -123,6 +127,24 @@ func TestParse_RealitySecurityMapped(t *testing.T) {
 	}
 }
 
+// Xray-core drops unknown JSON keys silently, so a key its REALITYConfig lacks
+// would reach the outbound as a setting that does nothing.
+func TestParse_RealitySettingsAreXrayFields(t *testing.T) {
+	res, err := ParseLink("vless://[email protected]:443?type=tcp&security=reality&pbk=PBK&sid=SID&sni=SNI&fp=firefox&spx=%2Fspx&pqv=PQV&support-x25519mlkem768=true")
+	if err != nil {
+		t.Fatalf("parse: %v", err)
+	}
+	raw, err := json.Marshal(streamSub(t, res, "realitySettings"))
+	if err != nil {
+		t.Fatalf("marshal: %v", err)
+	}
+	dec := json.NewDecoder(bytes.NewReader(raw))
+	dec.DisallowUnknownFields()
+	if err := dec.Decode(&conf.REALITYConfig{}); err != nil {
+		t.Fatalf("realitySettings %s is not an xray-core REALITY config: %v", raw, err)
+	}
+}
+
 func TestParse_TLSSecurityMapped(t *testing.T) {
 	res, err := ParseLink("trojan://[email protected]:443?type=tcp&security=tls&sni=SNI&fp=chrome&alpn=h2,http/1.1&ech=ECH&vcn=VCN&pcs=PCS")
 	if err != nil {