mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-20 19:01:17 +00:00
Merge 143a4d9863 into 74c3cc3882
This commit is contained in:
commit
6f2ddc450b
24 changed files with 370 additions and 26 deletions
|
|
@ -131,9 +131,11 @@ vless://<uuid>@<server>:443?security=reality&pbk=<public-key>&sid=<short-id>&sni
|
|||
with Mihomo's uTLS v1.8.7); enabling the flag cannot upgrade an old fingerprint.
|
||||
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.
|
||||
it on import. Servers on Xray-core older than 26.9.8 accept a plain `X25519`
|
||||
hello, and very old ones reject ML-KEM outright. For those, or where a network
|
||||
drops the larger ML-KEM ClientHello, enable **Mihomo X25519** on a Host: its
|
||||
Clash proxy gets `support-x25519mlkem768: false` and its `vless://` link drops
|
||||
the hint. Clearing the version limit alone does not fix the handshake.
|
||||
|
||||
</Callout>
|
||||
|
||||
|
|
|
|||
|
|
@ -140,9 +140,15 @@ vless://<uuid>@<server>:443?security=reality&pbk=<public-key>&sid=<short-id>&sni
|
|||
راهنمای معادل
|
||||
`support-x25519mlkem768=true`
|
||||
را منتقل میکند؛ کلاینتهایی که این افزونهٔ URI را پشتیبانی میکنند، از جمله نسخههای فعلی Mihomo، آن را هنگام ورود اعمال میکنند.
|
||||
برای سرورهای بسیار قدیمی که ML-KEM را رد میکنند، گزینه را برای همان گره در کلاینت روی
|
||||
`false`
|
||||
بگذارید یا سرور را ارتقا دهید. حذف محدودیت نسخه بهتنهایی دستدهی را اصلاح نمیکند.
|
||||
سرورهای Xray-core قدیمیتر از 26.9.8 دستدهی سادهٔ
|
||||
`X25519`
|
||||
را میپذیرند و سرورهای بسیار قدیمی ML-KEM را رد میکنند. برای آنها، یا اگر شبکه ClientHello بزرگتر ML-KEM را حذف میکند، گزینهٔ
|
||||
**Mihomo X25519**
|
||||
را در میزبان (Host) فعال کنید: پروکسی Clash آن مقدار
|
||||
`support-x25519mlkem768: false`
|
||||
را میگیرد و لینک
|
||||
`vless://`
|
||||
آن راهنما را ندارد. حذف محدودیت نسخه بهتنهایی دستدهی را اصلاح نمیکند.
|
||||
|
||||
</Callout>
|
||||
|
||||
|
|
|
|||
|
|
@ -135,10 +135,12 @@ vless://<uuid>@<server>:443?security=reality&pbk=<public-key>&sid=<short-id>&sni
|
|||
нужен отпечаток с ML-KEM (`chrome` при uTLS v1.8.7 в Mihomo). Сам флаг не
|
||||
обновляет старые отпечатки. Исходные ссылки `vless://` передают эквивалентную
|
||||
подсказку `support-x25519mlkem768=true`; клиенты, поддерживающие это расширение
|
||||
URI, включая актуальные сборки Mihomo, применяют её при импорте. Для очень старых
|
||||
серверов REALITY, отвергающих ML-KEM, задайте `false` для соответствующего узла
|
||||
в клиенте или обновите сервер. Снятие ограничения версии не исправляет это
|
||||
рукопожатие.
|
||||
URI, включая актуальные сборки Mihomo, применяют её при импорте. Серверы на
|
||||
Xray-core ниже 26.9.8 принимают обычный `X25519`, а очень старые отвергают
|
||||
ML-KEM. Для них, а также если сеть отбрасывает крупный ClientHello с ML-KEM,
|
||||
включите **Mihomo X25519** в хосте: его прокси в Clash получит
|
||||
`support-x25519mlkem768: false`, а ссылка `vless://` — без подсказки. Снятие
|
||||
ограничения версии не исправляет это рукопожатие.
|
||||
|
||||
</Callout>
|
||||
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ vless://<uuid>@<server>:443?security=reality&pbk=<public-key>&sid=<short-id>&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://` 链接会携带等效的 `support-x25519mlkem768=true` 提示;支持此 URI 扩展的客户端(包括当前 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 版本)会在导入时应用它。Xray-core 26.9.8 以下的服务端接受普通 `X25519` 握手,很旧的服务端则会拒绝 ML-KEM。对这些服务端,或网络会丢弃较大的 ML-KEM ClientHello 时,可在主机(Host)上启用 **Mihomo X25519**:该主机的 Clash 代理会得到 `support-x25519mlkem768: false`,`vless://` 链接也不再携带该提示。仅清空版本限制无法解决握手问题。
|
||||
|
||||
</Callout>
|
||||
|
||||
|
|
|
|||
|
|
@ -509,6 +509,7 @@ export default function HostFormModal({
|
|||
<FormField
|
||||
name="mihomoX25519"
|
||||
label={t('pages.hosts.fields.mihomoX25519')}
|
||||
tooltip={t('pages.hosts.hints.mihomoX25519')}
|
||||
valueProp="checked"
|
||||
>
|
||||
<Switch />
|
||||
|
|
|
|||
|
|
@ -431,6 +431,17 @@ func migrateHostVerifyPeerCertByNameColumn() error {
|
|||
return db.Exec(`UPDATE hosts SET verify_peer_cert_by_name = '' WHERE verify_peer_cert_by_name IS NULL OR typeof(verify_peer_cert_by_name) <> 'text'`).Error
|
||||
}
|
||||
|
||||
// mihomo_x25519 was saved but ignored until it began dropping ML-KEM, so rows set
|
||||
// back then are cleared once rather than silently changing their subscriptions.
|
||||
func resetPreexistingMihomoX25519() error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&model.Host{}).Where("mihomo_x25519 = ?", true).Update("mihomo_x25519", false).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&model.HistoryOfSeeders{SeederName: "HostMihomoX25519Reset"}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// AutoMigrate casts the boolean-era column to text before the migration above runs,
|
||||
// so a saved false/true reads back as the cert name "0"/"false"/"1"/"true".
|
||||
func blankLegacyBoolVerifyPeerCertByName() error {
|
||||
|
|
@ -1362,7 +1373,7 @@ func runSeeders(isUsersEmpty bool) error {
|
|||
}
|
||||
|
||||
if empty && isUsersEmpty {
|
||||
seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "UppercaseFreedomFinalRulesFix", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "OutboundRemovedKeysFix", "FreedomDomainStrategyFix", "DNSOutboundLegacyKeysFix", "DNSOutboundQTypeZeroFix", "WireguardDomainStrategyFix", "XdnsFinalmaskNamesAddrsFix", "UDPHopClientMaskFix", "HostVerifyPeerCertByNameBoolFix", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "ResetIpLimitNoFail2ban"}
|
||||
seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "UppercaseFreedomFinalRulesFix", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "OutboundRemovedKeysFix", "FreedomDomainStrategyFix", "DNSOutboundLegacyKeysFix", "DNSOutboundQTypeZeroFix", "WireguardDomainStrategyFix", "XdnsFinalmaskNamesAddrsFix", "UDPHopClientMaskFix", "HostVerifyPeerCertByNameBoolFix", "HostMihomoX25519Reset", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "ResetIpLimitNoFail2ban"}
|
||||
for _, name := range seeders {
|
||||
if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
|
||||
return err
|
||||
|
|
@ -1521,6 +1532,12 @@ func runSeeders(isUsersEmpty bool) error {
|
|||
}
|
||||
}
|
||||
|
||||
if !slices.Contains(seedersHistory, "HostMihomoX25519Reset") {
|
||||
if err := resetPreexistingMihomoX25519(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !slices.Contains(seedersHistory, "NodeInboundsAdopted") {
|
||||
if err := seedNodeInboundsAdopted(); err != nil {
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -459,3 +459,45 @@ func TestMigrateMtprotoCustomShareAddrWithUnusableSameAddressHost(t *testing.T)
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Mihomo X25519 was stored but ignored before it started dropping ML-KEM; a row
|
||||
// switched on back then must not change its subscription output on upgrade.
|
||||
func TestMigrate_PreexistingMihomoX25519IsResetOnce(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "x-ui.db")
|
||||
if err := InitDB(dbPath); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = CloseDB() })
|
||||
ib := seedInboundWithStream(t, "mx-legacy", 5559, `{"network":"tcp","security":"reality"}`)
|
||||
host := &model.Host{InboundId: ib.Id, Remark: "legacy", Address: "cdn.example.com", MihomoX25519: true}
|
||||
if err := GetDB().Create(host).Error; err != nil {
|
||||
t.Fatalf("seed host: %v", err)
|
||||
}
|
||||
if err := GetDB().Where("seeder_name = ?", "HostMihomoX25519Reset").Delete(&model.HistoryOfSeeders{}).Error; err != nil {
|
||||
t.Fatalf("clear seeder history: %v", err)
|
||||
}
|
||||
reopen := func() model.Host {
|
||||
t.Helper()
|
||||
if err := CloseDB(); err != nil {
|
||||
t.Fatalf("CloseDB: %v", err)
|
||||
}
|
||||
if err := InitDB(dbPath); err != nil {
|
||||
t.Fatalf("reopen InitDB: %v", err)
|
||||
}
|
||||
var got model.Host
|
||||
if err := GetDB().First(&got, host.Id).Error; err != nil {
|
||||
t.Fatalf("load host: %v", err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
if got := reopen(); got.MihomoX25519 {
|
||||
t.Fatalf("pre-existing mihomoX25519 = true after upgrade, want it reset to false")
|
||||
}
|
||||
if err := GetDB().Model(&model.Host{}).Where("id = ?", host.Id).Update("mihomo_x25519", true).Error; err != nil {
|
||||
t.Fatalf("switch on after upgrade: %v", err)
|
||||
}
|
||||
if got := reopen(); !got.MihomoX25519 {
|
||||
t.Fatalf("mihomoX25519 set after the upgrade was reset again on restart")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -395,6 +395,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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,6 +98,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
|
||||
}
|
||||
|
|
@ -170,6 +173,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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -310,6 +316,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
|
||||
|
|
|
|||
|
|
@ -8,6 +8,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"
|
||||
|
|
@ -516,6 +518,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, "", 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, "", 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, "", 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)
|
||||
|
|
|
|||
|
|
@ -2118,24 +2118,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
|
||||
|
|
|
|||
|
|
@ -2363,6 +2363,7 @@
|
|||
"serverDescription": "ملاحظة اختيارية تظهر تحت الملاحظة.",
|
||||
"allowInsecure": "تخطّي التحقق من شهادة TLS (allowInsecure / skip-cert-verify).",
|
||||
"vlessRoute": "قيمة مسار VLESS واحدة (0-65535) تُدمَج في UUID، مثل 443. اتركه فارغاً لعدم وجود أي منها.",
|
||||
"mihomoX25519": "Mihomo فقط: مصافحة REALITY باستخدام X25519 العادي بدون ML-KEM (support-x25519mlkem768: false). للخوادم التي تعمل بإصدار Xray-core أقدم من 26.9.8 ولا تتطلب ML-KEM.",
|
||||
"remark": "تسمية بسيطة لهذا المضيف. تظهر كاسم للإعداد فقط عندما لا يكون للوارد ملاحظة خاصة به."
|
||||
},
|
||||
"remarkVars": {
|
||||
|
|
|
|||
|
|
@ -1112,6 +1112,7 @@
|
|||
"serverDescription": "Optional note shown under the remark.",
|
||||
"allowInsecure": "Skip TLS certificate verification (allowInsecure / skip-cert-verify).",
|
||||
"vlessRoute": "Single VLESS route value (0-65535) baked into the UUID, e.g. 443. Leave blank for none.",
|
||||
"mihomoX25519": "Mihomo only: use a plain X25519 REALITY hello without ML-KEM (support-x25519mlkem768: false). For servers on Xray-core older than 26.9.8, which do not require ML-KEM.",
|
||||
"remark": "A plain label for this host. Shown as the config name only when the inbound has no remark of its own."
|
||||
},
|
||||
"remarkVars": {
|
||||
|
|
|
|||
|
|
@ -2363,6 +2363,7 @@
|
|||
"serverDescription": "Nota opcional que se muestra bajo las notas.",
|
||||
"allowInsecure": "Omitir la verificación del certificado TLS (allowInsecure / skip-cert-verify).",
|
||||
"vlessRoute": "Un único valor de ruta VLESS (0-65535) incrustado en el UUID, p. ej. 443. Déjalo en blanco para ninguno.",
|
||||
"mihomoX25519": "Solo Mihomo: usa un saludo REALITY con X25519 simple, sin ML-KEM (support-x25519mlkem768: false). Para servidores con Xray-core anterior a 26.9.8, que no exigen ML-KEM.",
|
||||
"remark": "Una etiqueta simple para este host. Se muestra como nombre de la configuración solo cuando el inbound no tiene notas propias."
|
||||
},
|
||||
"remarkVars": {
|
||||
|
|
|
|||
|
|
@ -2363,6 +2363,7 @@
|
|||
"serverDescription": "یادداشت اختیاری که زیر نام نمایش داده میشود.",
|
||||
"allowInsecure": "رد کردن بررسی گواهی TLS (allowInsecure / skip-cert-verify).",
|
||||
"vlessRoute": "یک مقدار مسیر VLESS (0 تا 65535) که در UUID جاسازی میشود، مثلاً 443. برای هیچکدام خالی بگذارید.",
|
||||
"mihomoX25519": "فقط Mihomo: دستدهی REALITY با X25519 ساده و بدون ML-KEM (support-x25519mlkem768: false). برای سرورهایی با Xray-core قدیمیتر از 26.9.8 که به ML-KEM نیاز ندارند.",
|
||||
"remark": "یک برچسب ساده برای این میزبان. تنها زمانی بهعنوان نام کانفیگ نمایش داده میشود که اینباند نام مخصوص خود را نداشته باشد."
|
||||
},
|
||||
"remarkVars": {
|
||||
|
|
|
|||
|
|
@ -2363,6 +2363,7 @@
|
|||
"serverDescription": "Catatan opsional yang ditampilkan di bawah catatan.",
|
||||
"allowInsecure": "Lewati verifikasi sertifikat TLS (allowInsecure / skip-cert-verify).",
|
||||
"vlessRoute": "Satu nilai rute VLESS (0-65535) yang disisipkan ke UUID, mis. 443. Biarkan kosong jika tidak ada.",
|
||||
"mihomoX25519": "Hanya Mihomo: gunakan handshake REALITY dengan X25519 biasa tanpa ML-KEM (support-x25519mlkem768: false). Untuk server dengan Xray-core lebih lama dari 26.9.8 yang tidak mewajibkan ML-KEM.",
|
||||
"remark": "Label sederhana untuk host ini. Ditampilkan sebagai nama konfigurasi hanya ketika inbound tidak memiliki catatan tersendiri."
|
||||
},
|
||||
"remarkVars": {
|
||||
|
|
|
|||
|
|
@ -2363,6 +2363,7 @@
|
|||
"serverDescription": "備考の下に表示される任意のメモ。",
|
||||
"allowInsecure": "TLS 証明書の検証をスキップします(allowInsecure / skip-cert-verify)。",
|
||||
"vlessRoute": "UUID に埋め込まれる単一の VLESS ルート値(0〜65535)。例: 443。なしの場合は空欄にします。",
|
||||
"mihomoX25519": "Mihomo のみ: ML-KEM を使わず通常の X25519 で REALITY ハンドシェイクを行います (support-x25519mlkem768: false)。ML-KEM を必要としない Xray-core 26.9.8 未満のサーバー向けです。",
|
||||
"remark": "このホストのプレーンなラベル。インバウンド自身に備考がない場合にのみ設定名として表示されます。"
|
||||
},
|
||||
"remarkVars": {
|
||||
|
|
|
|||
|
|
@ -2363,6 +2363,7 @@
|
|||
"serverDescription": "Nota opcional exibida abaixo da observação.",
|
||||
"allowInsecure": "Ignorar a verificação do certificado TLS (allowInsecure / skip-cert-verify).",
|
||||
"vlessRoute": "Um único valor de rota VLESS (0-65535) embutido no UUID, ex.: 443. Deixe em branco para nenhum.",
|
||||
"mihomoX25519": "Somente Mihomo: usa um handshake REALITY com X25519 simples, sem ML-KEM (support-x25519mlkem768: false). Para servidores com Xray-core anterior à 26.9.8, que não exigem ML-KEM.",
|
||||
"remark": "Um rótulo simples para este host. Mostrado como nome da configuração apenas quando a entrada não tem observação própria."
|
||||
},
|
||||
"remarkVars": {
|
||||
|
|
|
|||
|
|
@ -2363,6 +2363,7 @@
|
|||
"serverDescription": "Необязательная заметка, отображаемая под примечанием.",
|
||||
"allowInsecure": "Пропустить проверку TLS-сертификата (allowInsecure / skip-cert-verify).",
|
||||
"vlessRoute": "Одно значение маршрута VLESS (0-65535), встраиваемое в UUID, напр. 443. Оставьте пустым, чтобы отключить.",
|
||||
"mihomoX25519": "Только для Mihomo: REALITY-рукопожатие с обычным X25519, без ML-KEM (support-x25519mlkem768: false). Для серверов на Xray-core ниже 26.9.8, которым ML-KEM не нужен.",
|
||||
"remark": "Обычная метка для этого хоста. Используется как имя конфигурации, только если у входящего нет собственного примечания."
|
||||
},
|
||||
"remarkVars": {
|
||||
|
|
|
|||
|
|
@ -2363,6 +2363,7 @@
|
|||
"serverDescription": "Açıklamanın altında gösterilen isteğe bağlı not.",
|
||||
"allowInsecure": "TLS sertifika doğrulamasını atla (allowInsecure / skip-cert-verify).",
|
||||
"vlessRoute": "UUID'ye gömülen tek bir VLESS rota değeri (0-65535), örn. 443. Hiçbiri için boş bırakın.",
|
||||
"mihomoX25519": "Yalnızca Mihomo: ML-KEM olmadan düz X25519 ile REALITY el sıkışması kullanır (support-x25519mlkem768: false). ML-KEM gerektirmeyen, 26.9.8'den eski Xray-core sunucuları için.",
|
||||
"remark": "Bu host için düz bir etiket. Yalnızca gelen bağlantının kendi açıklaması yoksa yapılandırma adı olarak gösterilir."
|
||||
},
|
||||
"remarkVars": {
|
||||
|
|
|
|||
|
|
@ -2363,6 +2363,7 @@
|
|||
"serverDescription": "Необов'язкова примітка, що показується під приміткою.",
|
||||
"allowInsecure": "Пропускати перевірку TLS-сертифіката (allowInsecure / skip-cert-verify).",
|
||||
"vlessRoute": "Одне значення маршруту VLESS (0-65535), що вбудовується в UUID, напр. 443. Залиште порожнім, щоб не використовувати.",
|
||||
"mihomoX25519": "Лише для Mihomo: рукостискання REALITY зі звичайним X25519, без ML-KEM (support-x25519mlkem768: false). Для серверів на Xray-core нижче 26.9.8, яким ML-KEM не потрібен.",
|
||||
"remark": "Звичайна мітка для цього хоста. Показується як назва конфігурації лише тоді, коли вхідний не має власної примітки."
|
||||
},
|
||||
"remarkVars": {
|
||||
|
|
|
|||
|
|
@ -2363,6 +2363,7 @@
|
|||
"serverDescription": "Ghi chú tùy chọn hiển thị bên dưới ghi chú.",
|
||||
"allowInsecure": "Bỏ qua xác minh chứng chỉ TLS (allowInsecure / skip-cert-verify).",
|
||||
"vlessRoute": "Một giá trị định tuyến VLESS (0-65535) được nhúng vào UUID, ví dụ 443. Để trống nếu không dùng.",
|
||||
"mihomoX25519": "Chỉ Mihomo: dùng bắt tay REALITY với X25519 thường, không có ML-KEM (support-x25519mlkem768: false). Dành cho máy chủ chạy Xray-core cũ hơn 26.9.8, không yêu cầu ML-KEM.",
|
||||
"remark": "Nhãn đơn giản cho host này. Chỉ hiển thị làm tên cấu hình khi inbound không có ghi chú riêng."
|
||||
},
|
||||
"remarkVars": {
|
||||
|
|
|
|||
|
|
@ -2363,6 +2363,7 @@
|
|||
"serverDescription": "可选备注,显示在备注下方。",
|
||||
"allowInsecure": "跳过 TLS 证书验证(allowInsecure / skip-cert-verify)。",
|
||||
"vlessRoute": "嵌入 UUID 的单个 VLESS 路由值(0-65535),例如 443。留空表示不路由。",
|
||||
"mihomoX25519": "仅 Mihomo:REALITY 握手只使用普通 X25519,不带 ML-KEM(support-x25519mlkem768: false)。适用于运行 Xray-core 26.9.8 以下、不要求 ML-KEM 的服务器。",
|
||||
"remark": "此主机的纯文本标签。仅当入站没有自己的备注时,才作为配置名称显示。"
|
||||
},
|
||||
"remarkVars": {
|
||||
|
|
|
|||
|
|
@ -2363,6 +2363,7 @@
|
|||
"serverDescription": "顯示於備註下方的選填註記。",
|
||||
"allowInsecure": "略過 TLS 憑證驗證(allowInsecure / skip-cert-verify)。",
|
||||
"vlessRoute": "嵌入 UUID 的單一 VLESS 路由值(0-65535),例如 443。留空表示無。",
|
||||
"mihomoX25519": "僅 Mihomo:REALITY 握手只使用一般 X25519,不帶 ML-KEM(support-x25519mlkem768: false)。適用於執行 Xray-core 26.9.8 以下、不要求 ML-KEM 的伺服器。",
|
||||
"remark": "此 Host 的純文字標籤。僅當入站本身沒有備註時,才作為配置名稱顯示。"
|
||||
},
|
||||
"remarkVars": {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue