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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user