mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-25 07:15:01 +03:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user