characterization_test.go 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  1. package sub
  2. import (
  3. "encoding/base64"
  4. "encoding/json"
  5. "strings"
  6. "testing"
  7. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  8. )
  9. // Characterization snapshots (Phase 0 of the Hosts feature). These lock the
  10. // CURRENT subscription-link output for the externalProxy paths so the Phase-1
  11. // ShareEndpoint refactor can be proven behavior-preserving: they must pass on
  12. // unchanged code and stay green, unedited, through every later phase. Assertions
  13. // are exact (==) where output is deterministic and Contains where a value is
  14. // randomized (reality spx) or hex-derived.
  15. const charClient = `{"id":"11111111-2222-4333-8444-555555555555","email":"user"}`
  16. // charVlessInbound builds a VLESS inbound with one client "user".
  17. func charVlessInbound(stream string) *model.Inbound {
  18. return &model.Inbound{
  19. Listen: "203.0.113.1",
  20. Port: 443,
  21. Protocol: model.VLESS,
  22. Remark: "char",
  23. Settings: `{"clients":[` + charClient + `],"decryption":"none","encryption":"none"}`,
  24. StreamSettings: stream,
  25. }
  26. }
  27. // C1 — VLESS, TLS base, 2 externalProxy entries (forceTls tls + none). Locks
  28. // buildExternalProxyURLLinks, applyExternalProxyTLSParams, the none-strip path,
  29. // per-entry ordering, and the "\n" join.
  30. func TestChar_C1_VlessExternalProxy(t *testing.T) {
  31. stream := `{
  32. "network":"tcp","security":"tls",
  33. "tcpSettings":{"header":{"type":"none"}},
  34. "tlsSettings":{"serverName":"base.sni","alpn":["h2"],"settings":{"fingerprint":"chrome"}},
  35. "externalProxy":[
  36. {"forceTls":"tls","dest":"cdn1.example.com","port":8443,"remark":"R1","sni":"sni1.example.com","fingerprint":"firefox","alpn":["h3","h2"],"pinnedPeerCertSha256":["UElO"]},
  37. {"forceTls":"none","dest":"cdn2.example.com","port":80,"remark":"R2"}
  38. ]
  39. }`
  40. s := &SubService{}
  41. got := s.genVlessLink(charVlessInbound(stream), "user")
  42. want := "vless://[email protected]:8443?alpn=h3%2Ch2&encryption=none&fp=firefox&pcs=UElO&security=tls&sni=sni1.example.com&type=tcp#char-R1\n" +
  43. "vless://[email protected]:80?encryption=none&security=none&type=tcp#char-R2"
  44. if got != want {
  45. t.Fatalf("C1 mismatch.\n got: %q\nwant: %q", got, want)
  46. }
  47. }
  48. // C4 — VLESS reality base + 1 externalProxy with forceTls "same". Locks the
  49. // "same keeps the base security (reality)" passthrough. spx is randomized so the
  50. // fixed fields are asserted by Contains.
  51. func TestChar_C4_VlessRealitySame(t *testing.T) {
  52. stream := `{
  53. "network":"tcp","security":"reality",
  54. "tcpSettings":{"header":{"type":"none"}},
  55. "realitySettings":{"serverNames":["reality.example.com"],"shortIds":["ab12cd"],"settings":{"publicKey":"PBKvalue","fingerprint":"firefox"}},
  56. "externalProxy":[{"forceTls":"same","dest":"cdn.example.com","port":2053,"remark":"RS"}]
  57. }`
  58. s := &SubService{}
  59. got := s.genVlessLink(charVlessInbound(stream), "user")
  60. wants := []string{
  61. "vless://[email protected]:2053",
  62. "security=reality",
  63. "sni=reality.example.com",
  64. "pbk=PBKvalue",
  65. "sid=ab12cd",
  66. "fp=firefox",
  67. "#char-RS",
  68. }
  69. for _, w := range wants {
  70. if !strings.Contains(got, w) {
  71. t.Fatalf("C4 missing %q\n got: %s", w, got)
  72. }
  73. }
  74. if strings.Count(got, "\n") != 0 {
  75. t.Fatalf("C4 expected a single link, got: %s", got)
  76. }
  77. }
  78. // C2 — VMess, TLS base, 2 externalProxy entries (forceTls same + none). Locks
  79. // buildVmessExternalProxyLinks, cloneVmessShareObj strip, the obj["tls"] rewrite,
  80. // and the int port. Asserts on the decoded JSON objects.
  81. func TestChar_C2_VmessExternalProxy(t *testing.T) {
  82. stream := `{
  83. "network":"tcp","security":"tls",
  84. "tcpSettings":{"header":{"type":"none"}},
  85. "tlsSettings":{"serverName":"base.sni","alpn":["h2"],"settings":{"fingerprint":"chrome"}},
  86. "externalProxy":[
  87. {"forceTls":"same","dest":"vm1.example.com","port":8443,"remark":"V1","sni":"sni1.example.com"},
  88. {"forceTls":"none","dest":"vm2.example.com","port":80,"remark":"V2"}
  89. ]
  90. }`
  91. in := &model.Inbound{
  92. Listen: "203.0.113.1",
  93. Port: 443,
  94. Protocol: model.VMESS,
  95. Remark: "char",
  96. Settings: `{"clients":[{"id":"11111111-2222-4333-8444-555555555555","email":"user","security":"auto"}]}`,
  97. StreamSettings: stream,
  98. }
  99. s := &SubService{}
  100. got := s.genVmessLink(in, "user")
  101. want := "vmess://ewogICJhZGQiOiAidm0xLmV4YW1wbGUuY29tIiwKICAiYWxwbiI6ICJoMiIsCiAgImZwIjogImNocm9tZSIsCiAgImlkIjogIjExMTExMTExLTIyMjItNDMzMy04NDQ0LTU1NTU1NTU1NTU1NSIsCiAgIm5ldCI6ICJ0Y3AiLAogICJwb3J0IjogODQ0MywKICAicHMiOiAiY2hhci1WMSIsCiAgInNjeSI6ICJhdXRvIiwKICAic25pIjogInNuaTEuZXhhbXBsZS5jb20iLAogICJ0bHMiOiAidGxzIiwKICAidHlwZSI6ICJub25lIiwKICAidiI6ICIyIgp9\n" +
  102. "vmess://ewogICJhZGQiOiAidm0yLmV4YW1wbGUuY29tIiwKICAiaWQiOiAiMTExMTExMTEtMjIyMi00MzMzLTg0NDQtNTU1NTU1NTU1NTU1IiwKICAibmV0IjogInRjcCIsCiAgInBvcnQiOiA4MCwKICAicHMiOiAiY2hhci1WMiIsCiAgInNjeSI6ICJhdXRvIiwKICAidGxzIjogIm5vbmUiLAogICJ0eXBlIjogIm5vbmUiLAogICJ2IjogIjIiCn0="
  103. if got != want {
  104. t.Fatalf("C2 mismatch.\n got: %q\nwant: %q", got, want)
  105. }
  106. // Sanity: decode both objects so a structural change is visible too.
  107. for i, part := range strings.Split(got, "\n") {
  108. raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(part, "vmess://"))
  109. if err != nil {
  110. t.Fatalf("C2 link %d not base64: %v", i, err)
  111. }
  112. var obj map[string]any
  113. if err := json.Unmarshal(raw, &obj); err != nil {
  114. t.Fatalf("C2 link %d not json: %v", i, err)
  115. }
  116. }
  117. }
  118. // C3a — Trojan, TLS base, 1 externalProxy entry. Locks userinfo encoding through
  119. // the shared builder.
  120. func TestChar_C3_TrojanExternalProxy(t *testing.T) {
  121. stream := `{
  122. "network":"tcp","security":"tls",
  123. "tcpSettings":{"header":{"type":"none"}},
  124. "tlsSettings":{"serverName":"base.sni","settings":{"fingerprint":"chrome"}},
  125. "externalProxy":[{"forceTls":"tls","dest":"tj.example.com","port":8443,"remark":"TJ","sni":"tj.sni"}]
  126. }`
  127. in := &model.Inbound{
  128. Listen: "203.0.113.1",
  129. Port: 443,
  130. Protocol: model.Trojan,
  131. Remark: "char",
  132. Settings: `{"clients":[{"password":"p@ss/w+rd=","email":"user"}]}`,
  133. StreamSettings: stream,
  134. }
  135. s := &SubService{}
  136. got := s.genTrojanLink(in, "user")
  137. want := "trojan://p%40ss%2Fw%2Brd%[email protected]:8443?fp=chrome&security=tls&sni=tj.sni&type=tcp#char-TJ"
  138. if got != want {
  139. t.Fatalf("C3-Trojan mismatch.\n got: %q\nwant: %q", got, want)
  140. }
  141. }
  142. // C3b — Shadowsocks 2022 (method[0]=='2'), TLS base, 1 externalProxy entry.
  143. // Locks the ss-2022 triple-segment userinfo path through the shared builder.
  144. func TestChar_C3_ShadowsocksExternalProxy(t *testing.T) {
  145. stream := `{
  146. "network":"tcp","security":"tls",
  147. "tcpSettings":{"header":{"type":"none"}},
  148. "tlsSettings":{"serverName":"base.sni","settings":{"fingerprint":"chrome"}},
  149. "externalProxy":[{"forceTls":"tls","dest":"ss.example.com","port":8443,"remark":"SS","sni":"ss.sni"}]
  150. }`
  151. in := &model.Inbound{
  152. Listen: "203.0.113.1",
  153. Port: 443,
  154. Protocol: model.Shadowsocks,
  155. Remark: "char",
  156. Settings: `{"method":"2022-blake3-aes-256-gcm","password":"inboundpw","clients":[{"password":"clientpw","email":"user"}]}`,
  157. StreamSettings: stream,
  158. }
  159. s := &SubService{}
  160. got := s.genShadowsocksLink(in, "user")
  161. want := "ss://MjAyMi1ibGFrZTMtYWVzLTI1Ni1nY206aW5ib3VuZHB3OmNsaWVudHB3@ss.example.com:8443?fp=chrome&security=tls&sni=ss.sni&type=tcp#char-SS"
  162. if got != want {
  163. t.Fatalf("C3-SS mismatch.\n got: %q\nwant: %q", got, want)
  164. }
  165. }
  166. // C6 — Hysteria2, TLS, 1 externalProxy entry with a cert pin. Guards that the
  167. // Hysteria generator stays on its own path (hex pinSHA256, not pcs) and is NOT
  168. // folded into the unified builder. Pin hex is derived, so Contains is used.
  169. func TestChar_C6_HysteriaExternalProxy(t *testing.T) {
  170. // base64 of 32 zero bytes -> a valid pin shape for hysteriaPinHex.
  171. pin := base64.StdEncoding.EncodeToString(make([]byte, 32))
  172. stream := `{
  173. "security":"tls",
  174. "tlsSettings":{"serverName":"hy.sni","alpn":["h3"],"settings":{"fingerprint":"chrome"}},
  175. "externalProxy":[{"forceTls":"same","dest":"hop.example.com","port":9443,"remark":"H1","pinnedPeerCertSha256":["` + pin + `"]}]
  176. }`
  177. in := &model.Inbound{
  178. Listen: "203.0.113.1",
  179. Port: 443,
  180. Protocol: model.Hysteria,
  181. Remark: "char",
  182. Settings: `{"version":2,"clients":[{"auth":"hyauth","email":"user"}]}`,
  183. StreamSettings: stream,
  184. }
  185. s := &SubService{}
  186. got := s.genHysteriaLink(in, "user")
  187. wants := []string{
  188. "hysteria2://[email protected]:9443",
  189. "security=tls",
  190. "sni=hy.sni",
  191. "pinSHA256=",
  192. "#char-H1",
  193. }
  194. for _, w := range wants {
  195. if !strings.Contains(got, w) {
  196. t.Fatalf("C6 missing %q\n got: %s", w, got)
  197. }
  198. }
  199. if strings.Contains(got, "pcs=") {
  200. t.Fatalf("C6 hysteria must not use pcs=, got: %s", got)
  201. }
  202. }