diff --git a/docs/components/tools/reality-config-generator.tsx b/docs/components/tools/reality-config-generator.tsx index d7b722317..7bbd2994f 100644 --- a/docs/components/tools/reality-config-generator.tsx +++ b/docs/components/tools/reality-config-generator.tsx @@ -66,6 +66,7 @@ export function RealityConfigGenerator() { fingerprint, spiderX: '/', flow: 'xtls-rprx-vision', + supportX25519Mlkem768: true, } : null; diff --git a/docs/content/docs/en/config/reality.mdx b/docs/content/docs/en/config/reality.mdx index d6874b5f5..4fa83c6ae 100644 --- a/docs/content/docs/en/config/reality.mdx +++ b/docs/content/docs/en/config/reality.mdx @@ -129,10 +129,11 @@ vless://@:443?security=reality&pbk=&sid=&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. diff --git a/docs/content/docs/fa/config/reality.mdx b/docs/content/docs/fa/config/reality.mdx index 399cc5861..5f2f44369 100644 --- a/docs/content/docs/fa/config/reality.mdx +++ b/docs/content/docs/fa/config/reality.mdx @@ -137,7 +137,9 @@ vless://@:443?security=reality&pbk=&sid=&sni در Mihomo از Chrome استفاده کنید. فعال کردن گزینه، اثر انگشت قدیمی را ارتقا نمی‌دهد. لینک خام `vless://` - این گزینه را منتقل نمی‌کند و هنگام ورود مستقیم، بازنویسی پایدار در کلاینت لازم است. + راهنمای معادل + `support-x25519mlkem768=true` + را منتقل می‌کند؛ کلاینت‌هایی که این افزونهٔ URI را پشتیبانی می‌کنند، از جمله نسخه‌های فعلی Mihomo، آن را هنگام ورود اعمال می‌کنند. برای سرورهای بسیار قدیمی که ML-KEM را رد می‌کنند، گزینه را برای همان گره در کلاینت روی `false` بگذارید یا سرور را ارتقا دهید. حذف محدودیت نسخه به‌تنهایی دست‌دهی را اصلاح نمی‌کند. diff --git a/docs/content/docs/ru/config/reality.mdx b/docs/content/docs/ru/config/reality.mdx index 74eb15fdb..b4cd5b632 100644 --- a/docs/content/docs/ru/config/reality.mdx +++ b/docs/content/docs/ru/config/reality.mdx @@ -133,11 +133,12 @@ vless://@:443?security=reality&pbk=&sid=&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` для соответствующего узла + в клиенте или обновите сервер. Снятие ограничения версии не исправляет это + рукопожатие. diff --git a/docs/content/docs/zh/config/reality.mdx b/docs/content/docs/zh/config/reality.mdx index 3659862d5..1eaeb2854 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://` 链接不携带这个 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`,或升级服务端。仅清空版本限制无法解决握手问题。 diff --git a/docs/lib/xray/reality.test.ts b/docs/lib/xray/reality.test.ts index 8838ee73a..0a3fa9596 100644 --- a/docs/lib/xray/reality.test.ts +++ b/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'); diff --git a/docs/lib/xray/reality.ts b/docs/lib/xray/reality.ts index c459bc0d0..c6c98bbd1 100644 --- a/docs/lib/xray/reality.ts +++ b/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`, }); diff --git a/docs/lib/xray/subscription.test.ts b/docs/lib/xray/subscription.test.ts index a79f78705..d28a19cd6 100644 --- a/docs/lib/xray/subscription.test.ts +++ b/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); diff --git a/docs/lib/xray/subscription.ts b/docs/lib/xray/subscription.ts index c06337a78..b6d09931a 100644 --- a/docs/lib/xray/subscription.ts +++ b/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 { 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; } diff --git a/frontend/src/lib/xray/inbound-link.ts b/frontend/src/lib/xray/inbound-link.ts index bf5a62ed3..701c89dc0 100644 --- a/frontend/src/lib/xray/inbound-link.ts +++ b/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); diff --git a/frontend/src/test/__snapshots__/inbound-link.test.ts.snap b/frontend/src/test/__snapshots__/inbound-link.test.ts.snap index e41bf18c4..b844d9f4c 100644 --- a/frontend/src/test/__snapshots__/inbound-link.test.ts.snap +++ b/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://trojan-test-pw-XYZ@override.test: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://22222222-3333-4444-9555-666666666666@override.test: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://22222222-3333-4444-9555-666666666666@override.test: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://8c14d6f7-2e3b-4a91-9d24-3f7a6b8c1e02@override.test: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://trojan-test-pw-XYZ@example.test: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://22222222-3333-4444-9555-666666666666@example.test: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://22222222-3333-4444-9555-666666666666@example.test: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://8c14d6f7-2e3b-4a91-9d24-3f7a6b8c1e02@example.test: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"`; diff --git a/frontend/src/test/happ-settings-presets.test.tsx b/frontend/src/test/happ-settings-presets.test.tsx index 845776f54..3b7c34266 100644 --- a/frontend/src/test/happ-settings-presets.test.tsx +++ b/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', diff --git a/frontend/src/test/inbound-link.test.ts b/frontend/src/test/inbound-link.test.ts index abf3d1d02..abda78ae8 100644 --- a/frontend/src/test/inbound-link.test.ts +++ b/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); diff --git a/frontend/src/test/outbound-link-parser.test.ts b/frontend/src/test/outbound-link-parser.test.ts index 8b2a437cf..6a69a6dc7 100644 --- a/frontend/src/test/outbound-link-parser.test.ts +++ b/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://11111111-2222-4333-8444-555555555555@srv.example: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) + .realitySettings as Record; + expect(savedReality).not.toHaveProperty('supportX25519Mlkem768'); }); it('parses encryption + pqv (post-quantum) into settings and mldsa65Verify', () => { diff --git a/internal/sub/endpoint.go b/internal/sub/endpoint.go index d00640ad5..5d8c9d34d 100644 --- a/internal/sub/endpoint.go +++ b/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) } } diff --git a/internal/sub/host_sub_test.go b/internal/sub/host_sub_test.go index c36fc1d65..5ae3ff9a2 100644 --- a/internal/sub/host_sub_test.go +++ b/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) } diff --git a/internal/sub/service.go b/internal/sub/service.go index 07043eee9..d777f6986 100644 --- a/internal/sub/service.go +++ b/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" } diff --git a/internal/sub/service_sharelink_test.go b/internal/sub/service_sharelink_test.go index 88cc80a69..7da4367fb 100644 --- a/internal/sub/service_sharelink_test.go +++ b/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", diff --git a/internal/util/link/outbound_helpers_test.go b/internal/util/link/outbound_helpers_test.go index 787f81c0c..a9d7f7f64 100644 --- a/internal/util/link/outbound_helpers_test.go +++ b/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://uuid@h.com:443?type=tcp&security=reality&pbk=PBK&sid=SID&sni=SNI&fp=firefox&spx=%2Fspx&pqv=PQV") + res, err := ParseLink("vless://uuid@h.com: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://uuid@h.com: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://pw@h.com:443?type=tcp&security=tls&sni=SNI&fp=chrome&alpn=h2,http/1.1&ech=ECH&vcn=VCN&pcs=PCS") if err != nil {