mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-03 20:53:41 +03:00
93847dd106
* fix(sub): keep the spider settings in a reality spiderX seed's query xray's REALITY client reads p, c, t, i and r from the spiderX query as its spider's own settings (padding, concurrency, times, interval, return). deriveSpiderX hashes the whole seed into a bare /path per client, so any query set on the inbound was dropped from every share link and JSON subscription, and the spider always ran with defaults. Keep the seed's query after the derived path. The hash input is unchanged, so every existing client's spx stays the same; only seeds that carry a query gain it. The frontend mirror and the cross-language vectors are updated together. * docs(sub): keep the deriveSpiderX comments within two lines Review feedback on #6694: the added lines pushed both doc blocks past the two-line cap.
201 lines
7.0 KiB
Go
201 lines
7.0 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)
|
|
}
|
|
}
|
|
|
|
// A seed's spider settings (p, c, t, i, r) ride along in the share link's spx,
|
|
// escaped so they stay inside that one parameter.
|
|
func TestGenVlessLink_RealitySpiderXKeepsSpiderSettings(t *testing.T) {
|
|
s := &SubService{}
|
|
inbound := realityTwoClientInbound()
|
|
inbound.StreamSettings = strings.Replace(inbound.StreamSettings, `"spiderX":"/seed"`, `"spiderX":"/seed?p=40-400&r=500-2000"`, 1)
|
|
|
|
link := s.genVlessLink(inbound, "alice")
|
|
if got, want := spxParam(t, link), "/09dd00b3f8c01f5?p=40-400&r=500-2000"; got != want {
|
|
t.Fatalf("spx = %q, want %q", got, want)
|
|
}
|
|
u, _ := url.Parse(link)
|
|
if u.Query().Get("p") != "" || u.Query().Get("r") != "" {
|
|
t.Fatalf("spider settings leaked out of spx into the link's own query: %q", link)
|
|
}
|
|
}
|
|
|
|
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"},
|
|
// xray reads p, c, t, i and r from the spiderX query as the spider's
|
|
// own settings, so the seed's query must survive the derivation.
|
|
"seed with spider settings": {"/seed?p=40-400&r=500-2000", "subAlice", "/09dd00b3f8c01f5?p=40-400&r=500-2000"},
|
|
"spider settings only": {"/?p=40-400&c=1-4&t=1-3&i=1500-6000&r=500-2000", "", "/ac2cb268d22908e?p=40-400&c=1-4&t=1-3&i=1500-6000&r=500-2000"},
|
|
}
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|