external_subscription.go 3.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140
  1. package sub
  2. import (
  3. "context"
  4. "encoding/base64"
  5. "io"
  6. "net/http"
  7. "strings"
  8. "sync"
  9. "time"
  10. )
  11. // External subscription fetching: a "subscription" external link is a remote
  12. // URL whose body is a (often base64-encoded) newline list of share links. We
  13. // fetch it on demand, cache the decoded links briefly, and bound the request
  14. // with a short timeout so a slow/dead provider can't stall a client's sub.
  15. const (
  16. subscriptionCacheTTL = 5 * time.Minute
  17. subscriptionMaxBytes = 2 << 20 // 2 MiB
  18. )
  19. var subscriptionHTTPClient = &http.Client{Timeout: 6 * time.Second}
  20. type subscriptionCacheEntry struct {
  21. links []string
  22. fetchedAt time.Time
  23. }
  24. var subscriptionCache = struct {
  25. sync.Mutex
  26. m map[string]subscriptionCacheEntry
  27. }{m: make(map[string]subscriptionCacheEntry)}
  28. // fetchSubscriptionLinks returns the share links contained in a remote
  29. // subscription URL, using a short-lived cache. On any failure it returns the
  30. // last cached value (if present) or nil — never an error, so the rest of the
  31. // client's subscription still renders.
  32. func fetchSubscriptionLinks(rawURL string) []string {
  33. rawURL = strings.TrimSpace(rawURL)
  34. if rawURL == "" {
  35. return nil
  36. }
  37. subscriptionCache.Lock()
  38. cached, ok := subscriptionCache.m[rawURL]
  39. subscriptionCache.Unlock()
  40. if ok && time.Since(cached.fetchedAt) < subscriptionCacheTTL {
  41. return cached.links
  42. }
  43. links, err := doFetchSubscriptionLinks(rawURL)
  44. if err != nil {
  45. // Serve stale on error rather than dropping the client's configs.
  46. if ok {
  47. return cached.links
  48. }
  49. return nil
  50. }
  51. subscriptionCache.Lock()
  52. subscriptionCache.m[rawURL] = subscriptionCacheEntry{links: links, fetchedAt: time.Now()}
  53. subscriptionCache.Unlock()
  54. return links
  55. }
  56. func doFetchSubscriptionLinks(rawURL string) ([]string, error) {
  57. req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, rawURL, nil)
  58. if err != nil {
  59. return nil, err
  60. }
  61. // Some providers gate the link body on a known client User-Agent.
  62. req.Header.Set("User-Agent", "v2rayNG/1.8.5")
  63. resp, err := subscriptionHTTPClient.Do(req)
  64. if err != nil {
  65. return nil, err
  66. }
  67. defer resp.Body.Close()
  68. if resp.StatusCode < 200 || resp.StatusCode >= 300 {
  69. return nil, errBadStatus
  70. }
  71. body, err := io.ReadAll(io.LimitReader(resp.Body, subscriptionMaxBytes+1))
  72. if err != nil {
  73. return nil, err
  74. }
  75. if len(body) > subscriptionMaxBytes {
  76. return nil, errSubscriptionBodyTooLarge
  77. }
  78. return decodeSubscriptionBody(body), nil
  79. }
  80. var (
  81. errBadStatus = &subError{"non-2xx subscription response"}
  82. errSubscriptionBodyTooLarge = &subError{"subscription response body exceeds size limit"}
  83. )
  84. type subError struct{ msg string }
  85. func (e *subError) Error() string { return e.msg }
  86. // decodeSubscriptionBody handles the common base64-encoded newline list as well
  87. // as a plain-text body, returning only the lines that look like share links.
  88. func decodeSubscriptionBody(body []byte) []string {
  89. text := strings.TrimSpace(string(body))
  90. if text == "" {
  91. return nil
  92. }
  93. if decoded, ok := tryDecodeBase64Body(text); ok {
  94. text = strings.TrimSpace(decoded)
  95. }
  96. lines := strings.FieldsFunc(text, func(r rune) bool { return r == '\n' || r == '\r' })
  97. out := make([]string, 0, len(lines))
  98. for _, ln := range lines {
  99. ln = strings.TrimSpace(ln)
  100. if ln == "" || strings.HasPrefix(ln, "#") {
  101. continue
  102. }
  103. if strings.Contains(ln, "://") {
  104. out = append(out, ln)
  105. }
  106. }
  107. return out
  108. }
  109. func tryDecodeBase64Body(s string) (string, bool) {
  110. clean := strings.Map(func(r rune) rune {
  111. switch r {
  112. case ' ', '\n', '\r', '\t':
  113. return -1
  114. }
  115. return r
  116. }, s)
  117. if b, err := base64.StdEncoding.DecodeString(padBase64Sub(clean)); err == nil {
  118. return string(b), true
  119. }
  120. if b, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(clean, "=")); err == nil {
  121. return string(b), true
  122. }
  123. return "", false
  124. }