Files
3x-ui/internal/sub/service_sharelink_test.go
T
libmur-dev ce221c33d0 fix(sub): carry REALITY ML-KEM hint in VLESS links (#6712)
* fix(sub): carry REALITY ML-KEM hint in VLESS links

Keep raw share links in parity with Clash subscriptions for Xray 26.9.8+. Preserve the URI hint through Go and frontend imports, expose it in the outbound editor, and update the documentation tooling.

* fix(link): accept REALITY ML-KEM boolean aliases

* test(frontend): isolate Happ preset notifications

* fix(link): keep the ML-KEM hint out of Xray REALITY settings

support-x25519mlkem768 is a Mihomo reality-opts option; xray-core's
REALITYConfig (infra/conf/transport_security.go) has no such field and its
JSON loader drops unknown keys silently. The PR also stored it as
realitySettings.supportX25519Mlkem768 in Xray outbounds (form switch, Go and
TS link import, docs outbound builders) and as an inbound settings default
that is stripped before Xray and read by no link generator. The outbound
switch therefore did nothing, and imported links carried a dead key into the
JSON subscription.

The share-link hint itself stays: Go, frontend and docs still emit
support-x25519mlkem768=true on VLESS REALITY links and drop it on a TLS host
override.

---------

Co-authored-by: libmur-dev <333915961+libmur-dev@users.noreply.github.com>
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
2026-10-02 15:44:41 +02:00

180 lines
5.9 KiB
Go

package sub
import (
"net/url"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// shareLinkInbound builds a VLESS inbound with one client and the given stream
// settings, mirroring flowTestInbound but without forcing a flow.
func shareLinkInbound(streamSettings string) *model.Inbound {
return &model.Inbound{
Listen: "203.0.113.1",
Port: 443,
Protocol: model.VLESS,
Remark: "sharelink",
Settings: `{"clients":[{"id":"11111111-2222-4333-8444-555555555555","email":"user"}],"decryption":"none","encryption":"none"}`,
StreamSettings: streamSettings,
}
}
// TestGenVlessLink_TLSParamsMapped locks every field that applyShareTLSParams
// (service.go:1029) writes into a TLS share link. Without these assertions a mutant
// that drops `sni`, swaps a key, or skips `pcs`/`alpn`/`fp` survives the whole suite —
// the existing flow tests only check `flow=`.
func TestGenVlessLink_TLSParamsMapped(t *testing.T) {
stream := `{
"network":"tcp","security":"tls",
"tcpSettings":{"header":{"type":"none"}},
"tlsSettings":{
"serverName":"sni.example.com",
"alpn":["h2","http/1.1"],
"settings":{"fingerprint":"chrome","pinnedPeerCertSha256":["YWJj"]}
}
}`
s := &SubService{}
link := s.genVlessLink(shareLinkInbound(stream), "user")
// url.Values.Encode() percent-encodes values: "," -> %2C, "/" -> %2F.
wants := []string{
"security=tls",
"sni=sni.example.com",
"fp=chrome",
"alpn=h2%2Chttp%2F1.1",
"pcs=YWJj",
}
for _, w := range wants {
if !strings.Contains(link, w) {
t.Fatalf("TLS link missing %q\n got: %s", w, link)
}
}
}
// Locks the reality field mapping of applyShareRealityParams; distinct pbk/sid
// catch a swap mutant. spx is now a per-client derived value (#5718 / follow-up).
func TestGenVlessLink_RealityParamsMapped(t *testing.T) {
stream := `{
"network":"tcp","security":"reality",
"tcpSettings":{"header":{"type":"none"}},
"realitySettings":{
"serverNames":["reality.example.com"],
"shortIds":["ab12cd"],
"settings":{"publicKey":"PBKvalue","fingerprint":"firefox","spiderX":"/mypath"}
}
}`
s := &SubService{}
link := s.genVlessLink(shareLinkInbound(stream), "user")
wants := []string{
"security=reality",
"support-x25519mlkem768=true",
"sni=reality.example.com",
"pbk=PBKvalue",
"sid=ab12cd",
"fp=firefox",
"spx=%2F",
}
for _, w := range wants {
if !strings.Contains(link, w) {
t.Fatalf("reality link missing %q\n got: %s", w, link)
}
}
// A pbk<->sid swap must not silently pass: pbk must not carry the shortId.
if strings.Contains(link, "pbk=ab12cd") || strings.Contains(link, "sid=PBKvalue") {
t.Fatalf("reality pbk/sid mapping crossed: %s", link)
}
}
// realityTwoClientInbound builds a reality VLESS inbound carrying two clients
// with distinct subIds so the per-client spx derivation can be exercised.
func realityTwoClientInbound() *model.Inbound {
return &model.Inbound{
Listen: "203.0.113.1",
Port: 443,
Protocol: model.VLESS,
Remark: "sharelink",
Settings: `{"clients":[
{"id":"11111111-2222-4333-8444-555555555555","email":"alice","subId":"subAlice"},
{"id":"22222222-3333-4444-8555-666666666666","email":"bob","subId":"subBob"}
],"decryption":"none","encryption":"none"}`,
StreamSettings: `{
"network":"tcp","security":"reality",
"tcpSettings":{"header":{"type":"none"}},
"realitySettings":{
"serverNames":["reality.example.com"],
"shortIds":["ab12cd"],
"settings":{"publicKey":"PBKvalue","fingerprint":"firefox","spiderX":"/seed"}
}
}`,
}
}
func spxParam(t *testing.T, link string) string {
t.Helper()
u, err := url.Parse(link)
if err != nil {
t.Fatalf("parse link %q: %v", link, err)
}
spx := u.Query().Get("spx")
if spx == "" || spx[0] != '/' {
t.Fatalf("spx missing or not /-prefixed in %q", link)
}
return spx
}
// spx must be stable for a given client across repeated exports (the #5718
// complaint) yet differ between clients so the value can't be fingerprinted.
func TestGenVlessLink_RealitySpiderXPerClientStable(t *testing.T) {
s := &SubService{}
inbound := realityTwoClientInbound()
aliceFirst := spxParam(t, s.genVlessLink(inbound, "alice"))
aliceSecond := spxParam(t, s.genVlessLink(inbound, "alice"))
bob := spxParam(t, s.genVlessLink(inbound, "bob"))
if aliceFirst != aliceSecond {
t.Fatalf("spx not stable for the same client: %q vs %q", aliceFirst, aliceSecond)
}
if aliceFirst == bob {
t.Fatalf("spx identical across clients (fingerprintable): %q", aliceFirst)
}
}
func TestDeriveSpiderX(t *testing.T) {
if got := deriveSpiderX("seed", "clientA"); got != deriveSpiderX("seed", "clientA") {
t.Fatalf("deriveSpiderX not deterministic: %q", got)
}
if deriveSpiderX("seed", "clientA") == deriveSpiderX("seed", "clientB") {
t.Fatal("deriveSpiderX must differ per client")
}
if deriveSpiderX("seedA", "clientA") == deriveSpiderX("seedB", "clientA") {
t.Fatal("rotating the seed must rotate a client's spx")
}
got := deriveSpiderX("seed", "clientA")
if len(got) != 16 || got[0] != '/' {
t.Fatalf("deriveSpiderX shape = %q, want /-prefixed 15-char path", got)
}
if fallback := deriveSpiderX("", ""); len(fallback) != 16 || fallback[0] != '/' {
t.Fatalf("empty-input fallback = %q, want /-prefixed path", fallback)
}
}
// Cross-language vectors shared with frontend/src/test/spider-x.test.ts: the
// panel builds these links in TS, so both derivations must agree byte-for-byte.
func TestDeriveSpiderXMatchesFrontendVectors(t *testing.T) {
vectors := map[string]struct{ seed, clientKey, want string }{
"seed and subId": {"/seed", "subAlice", "/c252fbc3ecd3e3c"},
"seed only": {"/", "", "/d08ed99bd9afc60"},
}
for name, v := range vectors {
t.Run(name, func(t *testing.T) {
if got := deriveSpiderX(v.seed, v.clientKey); got != v.want {
t.Fatalf("deriveSpiderX(%q, %q) = %q, want %q (must match frontend/src/lib/xray/spider-x.ts)", v.seed, v.clientKey, got, v.want)
}
})
}
}