1
0

service_sharelink_test.go 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. package sub
  2. import (
  3. "net/url"
  4. "strings"
  5. "testing"
  6. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  7. )
  8. // shareLinkInbound builds a VLESS inbound with one client and the given stream
  9. // settings, mirroring flowTestInbound but without forcing a flow.
  10. func shareLinkInbound(streamSettings string) *model.Inbound {
  11. return &model.Inbound{
  12. Listen: "203.0.113.1",
  13. Port: 443,
  14. Protocol: model.VLESS,
  15. Remark: "sharelink",
  16. Settings: `{"clients":[{"id":"11111111-2222-4333-8444-555555555555","email":"user"}],"decryption":"none","encryption":"none"}`,
  17. StreamSettings: streamSettings,
  18. }
  19. }
  20. // TestGenVlessLink_TLSParamsMapped locks every field that applyShareTLSParams
  21. // (service.go:1029) writes into a TLS share link. Without these assertions a mutant
  22. // that drops `sni`, swaps a key, or skips `pcs`/`alpn`/`fp` survives the whole suite —
  23. // the existing flow tests only check `flow=`.
  24. func TestGenVlessLink_TLSParamsMapped(t *testing.T) {
  25. stream := `{
  26. "network":"tcp","security":"tls",
  27. "tcpSettings":{"header":{"type":"none"}},
  28. "tlsSettings":{
  29. "serverName":"sni.example.com",
  30. "alpn":["h2","http/1.1"],
  31. "settings":{"fingerprint":"chrome","pinnedPeerCertSha256":["YWJj"]}
  32. }
  33. }`
  34. s := &SubService{}
  35. link := s.genVlessLink(shareLinkInbound(stream), "user")
  36. // url.Values.Encode() percent-encodes values: "," -> %2C, "/" -> %2F.
  37. wants := []string{
  38. "security=tls",
  39. "sni=sni.example.com",
  40. "fp=chrome",
  41. "alpn=h2%2Chttp%2F1.1",
  42. "pcs=YWJj",
  43. }
  44. for _, w := range wants {
  45. if !strings.Contains(link, w) {
  46. t.Fatalf("TLS link missing %q\n got: %s", w, link)
  47. }
  48. }
  49. }
  50. // Locks the reality field mapping of applyShareRealityParams; distinct pbk/sid
  51. // catch a swap mutant. spx is now a per-client derived value (#5718 / follow-up).
  52. func TestGenVlessLink_RealityParamsMapped(t *testing.T) {
  53. stream := `{
  54. "network":"tcp","security":"reality",
  55. "tcpSettings":{"header":{"type":"none"}},
  56. "realitySettings":{
  57. "serverNames":["reality.example.com"],
  58. "shortIds":["ab12cd"],
  59. "settings":{"publicKey":"PBKvalue","fingerprint":"firefox","spiderX":"/mypath"}
  60. }
  61. }`
  62. s := &SubService{}
  63. link := s.genVlessLink(shareLinkInbound(stream), "user")
  64. wants := []string{
  65. "security=reality",
  66. "support-x25519mlkem768=true",
  67. "sni=reality.example.com",
  68. "pbk=PBKvalue",
  69. "sid=ab12cd",
  70. "fp=firefox",
  71. "spx=%2F",
  72. }
  73. for _, w := range wants {
  74. if !strings.Contains(link, w) {
  75. t.Fatalf("reality link missing %q\n got: %s", w, link)
  76. }
  77. }
  78. // A pbk<->sid swap must not silently pass: pbk must not carry the shortId.
  79. if strings.Contains(link, "pbk=ab12cd") || strings.Contains(link, "sid=PBKvalue") {
  80. t.Fatalf("reality pbk/sid mapping crossed: %s", link)
  81. }
  82. }
  83. // realityTwoClientInbound builds a reality VLESS inbound carrying two clients
  84. // with distinct subIds so the per-client spx derivation can be exercised.
  85. func realityTwoClientInbound() *model.Inbound {
  86. return &model.Inbound{
  87. Listen: "203.0.113.1",
  88. Port: 443,
  89. Protocol: model.VLESS,
  90. Remark: "sharelink",
  91. Settings: `{"clients":[
  92. {"id":"11111111-2222-4333-8444-555555555555","email":"alice","subId":"subAlice"},
  93. {"id":"22222222-3333-4444-8555-666666666666","email":"bob","subId":"subBob"}
  94. ],"decryption":"none","encryption":"none"}`,
  95. StreamSettings: `{
  96. "network":"tcp","security":"reality",
  97. "tcpSettings":{"header":{"type":"none"}},
  98. "realitySettings":{
  99. "serverNames":["reality.example.com"],
  100. "shortIds":["ab12cd"],
  101. "settings":{"publicKey":"PBKvalue","fingerprint":"firefox","spiderX":"/seed"}
  102. }
  103. }`,
  104. }
  105. }
  106. func spxParam(t *testing.T, link string) string {
  107. t.Helper()
  108. u, err := url.Parse(link)
  109. if err != nil {
  110. t.Fatalf("parse link %q: %v", link, err)
  111. }
  112. spx := u.Query().Get("spx")
  113. if spx == "" || spx[0] != '/' {
  114. t.Fatalf("spx missing or not /-prefixed in %q", link)
  115. }
  116. return spx
  117. }
  118. // spx must be stable for a given client across repeated exports (the #5718
  119. // complaint) yet differ between clients so the value can't be fingerprinted.
  120. func TestGenVlessLink_RealitySpiderXPerClientStable(t *testing.T) {
  121. s := &SubService{}
  122. inbound := realityTwoClientInbound()
  123. aliceFirst := spxParam(t, s.genVlessLink(inbound, "alice"))
  124. aliceSecond := spxParam(t, s.genVlessLink(inbound, "alice"))
  125. bob := spxParam(t, s.genVlessLink(inbound, "bob"))
  126. if aliceFirst != aliceSecond {
  127. t.Fatalf("spx not stable for the same client: %q vs %q", aliceFirst, aliceSecond)
  128. }
  129. if aliceFirst == bob {
  130. t.Fatalf("spx identical across clients (fingerprintable): %q", aliceFirst)
  131. }
  132. }
  133. func TestDeriveSpiderX(t *testing.T) {
  134. if got := deriveSpiderX("seed", "clientA"); got != deriveSpiderX("seed", "clientA") {
  135. t.Fatalf("deriveSpiderX not deterministic: %q", got)
  136. }
  137. if deriveSpiderX("seed", "clientA") == deriveSpiderX("seed", "clientB") {
  138. t.Fatal("deriveSpiderX must differ per client")
  139. }
  140. if deriveSpiderX("seedA", "clientA") == deriveSpiderX("seedB", "clientA") {
  141. t.Fatal("rotating the seed must rotate a client's spx")
  142. }
  143. got := deriveSpiderX("seed", "clientA")
  144. if len(got) != 16 || got[0] != '/' {
  145. t.Fatalf("deriveSpiderX shape = %q, want /-prefixed 15-char path", got)
  146. }
  147. if fallback := deriveSpiderX("", ""); len(fallback) != 16 || fallback[0] != '/' {
  148. t.Fatalf("empty-input fallback = %q, want /-prefixed path", fallback)
  149. }
  150. }
  151. // Cross-language vectors shared with frontend/src/test/spider-x.test.ts: the
  152. // panel builds these links in TS, so both derivations must agree byte-for-byte.
  153. func TestDeriveSpiderXMatchesFrontendVectors(t *testing.T) {
  154. vectors := map[string]struct{ seed, clientKey, want string }{
  155. "seed and subId": {"/seed", "subAlice", "/c252fbc3ecd3e3c"},
  156. "seed only": {"/", "", "/d08ed99bd9afc60"},
  157. }
  158. for name, v := range vectors {
  159. t.Run(name, func(t *testing.T) {
  160. if got := deriveSpiderX(v.seed, v.clientKey); got != v.want {
  161. t.Fatalf("deriveSpiderX(%q, %q) = %q, want %q (must match frontend/src/lib/xray/spider-x.ts)", v.seed, v.clientKey, got, v.want)
  162. }
  163. })
  164. }
  165. }