feat(sub): make the host "Mihomo X25519" switch drop ML-KEM (#6803)

The per-host Mihomo X25519 switch has been stored since managed hosts landed,
but no renderer read it, while every REALITY proxy was forced to
support-x25519mlkem768: true. On some networks DPI drops the second segment of
an ML-KEM ClientHello and blackholes the server for that client. A host with the
switch on now gets support-x25519mlkem768: false and no ML-KEM hint in its vless
link; switches saved while they had no effect are cleared once, so upgrading
does not silently change existing subscriptions.

A REALITY host's fingerprint now reaches the Clash and JSON outputs, and every
host endpoint gets its own deep copy of the stream, so one host's blank SNI,
fingerprint, Host header or path no longer inherits another host's value.
This commit is contained in:
Aleksey
2026-10-24 07:01:48 +03:00
committed by GitHub
parent a403faee23
commit 41bafb3fd5
24 changed files with 370 additions and 26 deletions
+7
View File
@@ -397,6 +397,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)
}
}
+16
View File
@@ -121,6 +121,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
}
@@ -193,6 +196,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
}
}
}
}
@@ -333,6 +339,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
+224
View File
@@ -9,6 +9,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"
@@ -517,6 +519,228 @@ 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, "", 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)
}
}
// 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, "", 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
// 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, "", 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)
+28 -14
View File
@@ -2121,24 +2121,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