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
+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)