mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-28 20:56:42 +08:00
a2ca023336
* fix(panel): accept 2FA codes from adjacent TOTP windows CheckUser compared only gotp.Now(), so a code submitted at the end of its 30s window (or with slight client/server clock drift) failed with 'invalid 2fa code', while the immediate retry in the next window succeeded. Accept current +/-1 window, the standard TOTP skew tolerance. Fixes MHSanaei/3x-ui#6535 * fix(panel): share TOTP skew tolerance with VerifyTwoFactorCode Move the +/-1 window helper to internal/util/totp so both 2FA acceptance points use it: login (CheckUser) and disable/rebind plus username/password changes (VerifyTwoFactorCode). Also shrink comments to the 2-line house rule and anchor the unit test mid-window to avoid a step-boundary flake. Addresses review on #6546 (MEDIUM + 2 LOWs). * fix(sub): send panel guid as X-HWID on outbound subscription fetch Outbound subscriptions hit the same HWID-limited donor 404 as client external links (#6559/#6567). Identify this panel with GetPanelGuid plus X-Device-OS, honoring the externalSubSendHwid opt-out. Fixes MHSanaei/3x-ui#6574 * fix(sub): send the external-subscription X-HWID from outbound fetches too The outbound fetch used panelGuid while client external links send the externalSubHwid id from #6567, so an HWID-limited provider counted one panel as two devices. It also re-added the externalSubSendHwid opt-out that #6567 dropped. Move the id into service.ExternalSubscriptionHwid, keeping the externalSubHwid row so existing installs keep their slot, and send it from both paths. The outbound test now fails on the panelGuid version. --------- Co-authored-by: sdhfsl <sdhfsl@users.noreply.github.com> Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
223 lines
6.2 KiB
Go
223 lines
6.2 KiB
Go
package sub
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
|
)
|
|
|
|
// External subscription fetching: a remote URL whose body is a share-link
|
|
// list. Fetches are cached briefly and bounded so a dead provider can't stall.
|
|
|
|
const (
|
|
subscriptionCacheTTL = 5 * time.Minute
|
|
subscriptionMaxBytes = 2 << 20 // 2 MiB
|
|
subscriptionCacheCapacity = 256
|
|
)
|
|
|
|
var subscriptionHTTPClient = &http.Client{Timeout: 6 * time.Second}
|
|
|
|
type subscriptionCacheEntry struct {
|
|
links []string
|
|
fetchedAt time.Time
|
|
}
|
|
|
|
type subscriptionFetch struct {
|
|
done chan struct{}
|
|
links []string
|
|
}
|
|
|
|
var subscriptionCache = struct {
|
|
sync.Mutex
|
|
m map[string]subscriptionCacheEntry
|
|
inflight map[string]*subscriptionFetch
|
|
}{
|
|
m: make(map[string]subscriptionCacheEntry),
|
|
inflight: make(map[string]*subscriptionFetch),
|
|
}
|
|
|
|
// subscriptionFetchResult reports whether this caller performed the network
|
|
// fetch, so only it records status and cache hits stay read-only.
|
|
type subscriptionFetchResult struct {
|
|
links []string
|
|
fetched bool
|
|
err error
|
|
}
|
|
|
|
// fetchSubscriptionLinks returns the share links contained in a remote
|
|
// subscription URL, using a short-lived cache. On any failure it returns the
|
|
// last cached value (if present) or nil — never an error, so the rest of the
|
|
// client's subscription still renders.
|
|
func fetchSubscriptionLinks(rawURL string) subscriptionFetchResult {
|
|
rawURL = strings.TrimSpace(rawURL)
|
|
if rawURL == "" {
|
|
return subscriptionFetchResult{}
|
|
}
|
|
|
|
subscriptionCache.Lock()
|
|
cached, ok := subscriptionCache.m[rawURL]
|
|
if ok && time.Since(cached.fetchedAt) < subscriptionCacheTTL {
|
|
subscriptionCache.Unlock()
|
|
return subscriptionFetchResult{links: cached.links}
|
|
}
|
|
if fetch, waiting := subscriptionCache.inflight[rawURL]; waiting {
|
|
subscriptionCache.Unlock()
|
|
<-fetch.done
|
|
return subscriptionFetchResult{links: fetch.links}
|
|
}
|
|
fetch := &subscriptionFetch{done: make(chan struct{})}
|
|
subscriptionCache.inflight[rawURL] = fetch
|
|
subscriptionCache.Unlock()
|
|
defer func() {
|
|
subscriptionCache.Lock()
|
|
close(fetch.done)
|
|
delete(subscriptionCache.inflight, rawURL)
|
|
subscriptionCache.Unlock()
|
|
}()
|
|
|
|
links, err := doFetchSubscriptionLinks(rawURL)
|
|
if err != nil {
|
|
if ok {
|
|
fetch.links = cached.links
|
|
}
|
|
return subscriptionFetchResult{links: fetch.links, fetched: true, err: err}
|
|
}
|
|
|
|
subscriptionCache.Lock()
|
|
subscriptionCache.m[rawURL] = subscriptionCacheEntry{links: links, fetchedAt: time.Now()}
|
|
trimSubscriptionCacheLocked(rawURL)
|
|
subscriptionCache.Unlock()
|
|
fetch.links = links
|
|
return subscriptionFetchResult{links: links, fetched: true}
|
|
}
|
|
|
|
func trimSubscriptionCacheLocked(keep string) {
|
|
for len(subscriptionCache.m) > subscriptionCacheCapacity {
|
|
var oldestURL string
|
|
var oldest time.Time
|
|
for rawURL, entry := range subscriptionCache.m {
|
|
if rawURL == keep {
|
|
continue
|
|
}
|
|
if oldestURL == "" || entry.fetchedAt.Before(oldest) {
|
|
oldestURL = rawURL
|
|
oldest = entry.fetchedAt
|
|
}
|
|
}
|
|
if oldestURL == "" {
|
|
return
|
|
}
|
|
delete(subscriptionCache.m, oldestURL)
|
|
}
|
|
}
|
|
|
|
// recordExternalSubscriptionFetch stamps status on every row holding this URL,
|
|
// keyed by value because row ids churn on save and the cache is per URL.
|
|
func recordExternalSubscriptionFetch(rawURL string, fetchErr error) {
|
|
rawURL = strings.TrimSpace(rawURL)
|
|
if rawURL == "" {
|
|
return
|
|
}
|
|
lastFetchError := ""
|
|
if fetchErr != nil {
|
|
lastFetchError = fetchErr.Error()
|
|
}
|
|
if err := database.GetDB().
|
|
Model(&model.ClientExternalLink{}).
|
|
Where("kind = ? AND value = ?", model.ExternalLinkKindSubscription, rawURL).
|
|
Updates(map[string]any{
|
|
"last_fetch_at": time.Now().UnixMilli(),
|
|
"last_fetch_error": lastFetchError,
|
|
}).Error; err != nil {
|
|
logger.Warningf("sub: recording fetch status for external subscription %q: %v", rawURL, err)
|
|
}
|
|
}
|
|
|
|
func doFetchSubscriptionLinks(rawURL string) ([]string, error) {
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, rawURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Some providers gate the link body on a known client User-Agent.
|
|
req.Header.Set("User-Agent", "v2rayNG/1.8.5")
|
|
// A 3x-ui donor with an HWID limit answers 404 when the header is empty (#6559).
|
|
if hwid := service.ExternalSubscriptionHwid(); hwid != "" {
|
|
req.Header.Set("X-HWID", hwid)
|
|
}
|
|
resp, err := subscriptionHTTPClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return nil, errBadStatus
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, subscriptionMaxBytes+1))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(body) > subscriptionMaxBytes {
|
|
return nil, errSubscriptionBodyTooLarge
|
|
}
|
|
return decodeSubscriptionBody(body), nil
|
|
}
|
|
|
|
var (
|
|
errBadStatus = &subError{"non-2xx subscription response"}
|
|
errSubscriptionBodyTooLarge = &subError{"subscription response body exceeds size limit"}
|
|
)
|
|
|
|
type subError struct{ msg string }
|
|
|
|
func (e *subError) Error() string { return e.msg }
|
|
|
|
// decodeSubscriptionBody handles the common base64-encoded newline list as well
|
|
// as a plain-text body, returning only the lines that look like share links.
|
|
func decodeSubscriptionBody(body []byte) []string {
|
|
text := strings.TrimSpace(string(body))
|
|
if text == "" {
|
|
return nil
|
|
}
|
|
if decoded, ok := tryDecodeBase64Body(text); ok {
|
|
text = strings.TrimSpace(decoded)
|
|
}
|
|
lines := strings.FieldsFunc(text, func(r rune) bool { return r == '\n' || r == '\r' })
|
|
out := make([]string, 0, len(lines))
|
|
for _, ln := range lines {
|
|
ln = strings.TrimSpace(ln)
|
|
if ln == "" || strings.HasPrefix(ln, "#") {
|
|
continue
|
|
}
|
|
if strings.Contains(ln, "://") {
|
|
out = append(out, ln)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func tryDecodeBase64Body(s string) (string, bool) {
|
|
clean := strings.Map(func(r rune) rune {
|
|
switch r {
|
|
case ' ', '\n', '\r', '\t':
|
|
return -1
|
|
}
|
|
return r
|
|
}, s)
|
|
if b, err := base64.StdEncoding.DecodeString(padBase64Sub(clean)); err == nil {
|
|
return string(b), true
|
|
}
|
|
if b, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(clean, "=")); err == nil {
|
|
return string(b), true
|
|
}
|
|
return "", false
|
|
}
|