external_subscription.go 3.7 KB

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