1
0

clash_external_quota_test.go 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118
  1. package sub
  2. import (
  3. "fmt"
  4. "net/http"
  5. "net/http/httptest"
  6. "strings"
  7. "testing"
  8. "time"
  9. "github.com/gin-gonic/gin"
  10. "github.com/mhsanaei/3x-ui/v3/internal/database"
  11. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  12. "github.com/mhsanaei/3x-ui/v3/internal/xray"
  13. )
  14. // base64("aes-256-gcm:clientpw"), the SIP002 userinfo both spellings share. The
  15. // panel emits this node as `plugin=obfs-local;obfs=http`, and Clash has no way
  16. // to represent it, so both the inbound path and the link path drop it.
  17. const clashDroppedExternalLink = "ss://[email protected]:8443?type=tcp&headerType=http&host=test#obfs"
  18. const tcpObfsStream = `{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"http","request":{"path":["/"],"headers":{"Host":["test"]}}}}}`
  19. func clashSubRouter(t *testing.T) *gin.Engine {
  20. t.Helper()
  21. oldDistFS := distFS
  22. distFS = testDistFS
  23. t.Cleanup(func() { distFS = oldDistFS })
  24. gin.SetMode(gin.TestMode)
  25. router := gin.New()
  26. NewSUBController(
  27. router.Group("/"),
  28. WithSUBJsonEnabled(true),
  29. WithSUBClashEnabled(true),
  30. WithSUBEncryption(false),
  31. )
  32. return router
  33. }
  34. func fetchClashSub(t *testing.T, router *gin.Engine, path string) *httptest.ResponseRecorder {
  35. t.Helper()
  36. req := httptest.NewRequest(http.MethodGet, path, nil)
  37. req.Host = "sub.example.com"
  38. w := httptest.NewRecorder()
  39. router.ServeHTTP(w, req)
  40. return w
  41. }
  42. func seedClashQuotaSub(t *testing.T, subID string, expiry int64) {
  43. t.Helper()
  44. db := database.GetDB()
  45. seedSubInbound(t, subID, "A", 10001, 1, wsTLSStream)
  46. if err := db.Create(&xray.ClientTraffic{Email: "A@e", Up: 11, Down: 22, Total: 1024, ExpiryTime: expiry}).Error; err != nil {
  47. t.Fatalf("seed A traffic: %v", err)
  48. }
  49. rec := &model.ClientRecord{Email: "B@e", SubID: subID, UUID: "22222222-2222-4222-8222-222222222222", Enable: true, ExpiryTime: expiry}
  50. if err := db.Create(rec).Error; err != nil {
  51. t.Fatalf("seed B client: %v", err)
  52. }
  53. if err := db.Create(&xray.ClientTraffic{Email: "B@e", Up: 100, Down: 200, Total: 2048, ExpiryTime: expiry}).Error; err != nil {
  54. t.Fatalf("seed B traffic: %v", err)
  55. }
  56. if err := db.Create(&model.ClientExternalLink{ClientId: rec.Id, Kind: model.ExternalLinkKindLink, Value: clashDroppedExternalLink, SortIndex: 1}).Error; err != nil {
  57. t.Fatalf("seed B external link: %v", err)
  58. }
  59. }
  60. // B's node cannot be represented in Clash, but B still owns quota: the header
  61. // must keep counting B's traffic, not quietly serve A's numbers alone.
  62. func TestClashQuotaHeaderCountsDroppedExternalLink(t *testing.T) {
  63. initSubDB(t)
  64. subID := "clash-quota-drop"
  65. expiry := time.Now().Add(24 * time.Hour).UnixMilli()
  66. seedClashQuotaSub(t, subID, expiry)
  67. w := fetchClashSub(t, clashSubRouter(t), "/clash/"+subID+"?view=raw")
  68. if w.Code != http.StatusOK {
  69. t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
  70. }
  71. if strings.Contains(w.Body.String(), "198.51.100.9") {
  72. t.Fatalf("unrepresentable node leaked into the profile: %s", w.Body.String())
  73. }
  74. wantHeader := fmt.Sprintf("upload=111; download=222; total=3072; expire=%d", expiry/1000)
  75. if got := w.Header().Get("Subscription-Userinfo"); got != wantHeader {
  76. t.Fatalf("Subscription-Userinfo = %q, want %q", got, wantHeader)
  77. }
  78. }
  79. // Nothing to serve is answered the same way whether the unrepresentable node is
  80. // an inbound or an external link — the drop must not depend on where it came from.
  81. func TestClashAllUnrepresentableNodesAnswerAlike(t *testing.T) {
  82. statuses := make(map[string]int, 2)
  83. for _, source := range []string{"inbound", "external-link"} {
  84. t.Run(source, func(t *testing.T) {
  85. initSubDB(t)
  86. if source == "inbound" {
  87. seedSubInbound(t, "clash-parity", "obfs", 10001, 1, tcpObfsStream)
  88. } else {
  89. rec := &model.ClientRecord{Email: "B@e", SubID: "clash-parity", UUID: "22222222-2222-4222-8222-222222222222", Enable: true}
  90. if err := database.GetDB().Create(rec).Error; err != nil {
  91. t.Fatalf("seed client: %v", err)
  92. }
  93. if err := database.GetDB().Create(&model.ClientExternalLink{ClientId: rec.Id, Kind: model.ExternalLinkKindLink, Value: clashDroppedExternalLink, SortIndex: 1}).Error; err != nil {
  94. t.Fatalf("seed external link: %v", err)
  95. }
  96. }
  97. w := fetchClashSub(t, clashSubRouter(t), "/clash/clash-parity?view=raw")
  98. if w.Body.Len() != 0 {
  99. t.Fatalf("body = %q, want empty", w.Body.String())
  100. }
  101. statuses[source] = w.Code
  102. })
  103. }
  104. if statuses["inbound"] != statuses["external-link"] {
  105. t.Fatalf("status differs by node source: %v", statuses)
  106. }
  107. }