From 227ed818b97f03b45b5555d8fdf836b14acc33fe Mon Sep 17 00:00:00 2001 From: sdhfsl Date: Wed, 16 Sep 2026 16:55:54 +0800 Subject: [PATCH] fix(sub): address review on external X-HWID - Serialize first-time id creation with a mutex so concurrent first fetches cannot mint two UUIDs. - Fix goimports grouping for the new third-party import. - Add externalSubSendHwid opt-out (default send); document it. - Cover header send/omit with httptest in TestFetchSendsStableHwid. --- internal/sub/external_hwid_test.go | 42 ++++++++++++++++++++++ internal/sub/external_subscription.go | 52 +++++++++++++++++++-------- 2 files changed, 80 insertions(+), 14 deletions(-) diff --git a/internal/sub/external_hwid_test.go b/internal/sub/external_hwid_test.go index df7249b57..3a9f4bc7e 100644 --- a/internal/sub/external_hwid_test.go +++ b/internal/sub/external_hwid_test.go @@ -1,6 +1,8 @@ package sub import ( + "net/http" + "net/http/httptest" "path/filepath" "strings" "testing" @@ -41,3 +43,43 @@ func TestServerHwidStableAcrossCalls(t *testing.T) { t.Fatalf("persisted hwid %q != returned %q", row.Value, first) } } + +// The fetch must carry the stable id by default so an HWID-limited donor +// lets it through, and must drop it when the operator opts out. +func TestFetchSendsStableHwid(t *testing.T) { + if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil { + t.Fatalf("InitDB: %v", err) + } + t.Cleanup(func() { _ = database.CloseDB() }) + + var gotHwid string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotHwid = r.Header.Get("X-HWID") + _, _ = w.Write([]byte("vless://uuid@host:443?security=none#x")) + })) + defer srv.Close() + + res := fetchSubscriptionLinks(srv.URL) + if res.err != nil { + t.Fatalf("fetch: %v", res.err) + } + if len(res.links) != 1 { + t.Fatalf("links = %v", res.links) + } + if gotHwid == "" { + t.Fatal("X-HWID header missing on fetch") + } + if gotHwid != serverHwid() { + t.Fatalf("sent %q != stable %q", gotHwid, serverHwid()) + } + + if err := database.GetDB().Create( + &model.Setting{Key: sendHwidKey, Value: "false"}).Error; err != nil { + t.Fatalf("opt out: %v", err) + } + gotHwid = "sentinel" + fetchSubscriptionLinks(srv.URL + "/other") + if gotHwid != "" { + t.Fatalf("X-HWID sent despite opt-out: %q", gotHwid) + } +} diff --git a/internal/sub/external_subscription.go b/internal/sub/external_subscription.go index 8f0cdb559..1d25df15c 100644 --- a/internal/sub/external_subscription.go +++ b/internal/sub/external_subscription.go @@ -10,15 +10,14 @@ import ( "time" "github.com/google/uuid" + "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" ) -// External subscription fetching: a "subscription" external link is a remote -// URL whose body is a (often base64-encoded) newline list of share links. We -// fetch it on demand, cache the decoded links briefly, and bound the request -// with a short timeout so a slow/dead provider can't stall a client's sub. +// 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 @@ -151,11 +150,12 @@ func doFetchSubscriptionLinks(rawURL string) ([]string, error) { } // 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). Identify this panel with a stable per-installation id so the - // donor registers exactly one device slot for it. - if hwid := serverHwid(); hwid != "" { - req.Header.Set("X-HWID", hwid) + // A 3x-ui donor with an HWID limit answers 404 when the header is + // empty (#6559). Send our stable id unless the operator opted out. + if sendServerHwid() { + if hwid := serverHwid(); hwid != "" { + req.Header.Set("X-HWID", hwid) + } } resp, err := subscriptionHTTPClient.Do(req) if err != nil { @@ -180,14 +180,38 @@ var ( errSubscriptionBodyTooLarge = &subError{"subscription response body exceeds size limit"} ) -// serverHwidKey is the settings row holding this panel's stable identity for -// outbound external-subscription fetches. +// serverHwidKey is the settings row holding this panel's stable identity +// for outbound external-subscription fetches. const serverHwidKey = "externalSubHwid" -// serverHwid returns a stable per-installation id, creating and persisting it -// on first use. A random-per-request value would burn one donor HWID slot per -// fetch; empty means the DB is unreachable, in which case no header is sent. +// sendHwidKey toggles the X-HWID header on external fetches. Default on; +// set to "false" to stop identifying this panel to third-party providers. +const sendHwidKey = "externalSubSendHwid" + +// sendServerHwid reports whether to attach our stable id. Missing row or +// parse failure keeps the default (send) so donor sync works out of box. +func sendServerHwid() bool { + db := database.GetDB() + if db == nil { + return true + } + var row model.Setting + if err := db.Where("key = ?", sendHwidKey).First(&row).Error; err != nil { + return true + } + v := strings.TrimSpace(strings.ToLower(row.Value)) + return v != "false" && v != "0" && v != "no" && v != "off" +} + +// serverHwidMu serializes first-time creation: without it, concurrent first +// fetches of different URLs each mint and persist their own UUID. +var serverHwidMu sync.Mutex + +// serverHwid returns a stable per-installation id, creating and persisting +// it on first use. Empty means the DB is unreachable: send no header then. func serverHwid() string { + serverHwidMu.Lock() + defer serverHwidMu.Unlock() db := database.GetDB() if db == nil { return ""