From d59b77bcdbc8151b4c5676e8bf0ca262a8094c2c Mon Sep 17 00:00:00 2001 From: sdhfsl Date: Wed, 16 Sep 2026 18:16:26 +0800 Subject: [PATCH] fix(sub): send stable X-HWID on external subscription fetch (#6567) * 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 stable X-HWID on external subscription fetch A Master panel fetching a donor subscription sent no X-HWID, so an HWID-limited donor rejected it with 404. Identify this panel with a stable per-installation id (persisted in settings), occupying exactly one donor device slot. Fixes MHSanaei/3x-ui#6559 * 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. * fix(sub): drop the SQL-only X-HWID opt-out The externalSubSendHwid opt-out added in 227ed818 had no settings field, CLI flag or docs, so an operator could only reach it by editing the settings table by hand, while every cache-miss fetch paid a query for it. CLAUDE.md rules out config knobs on a one-header fix. Also drop the test assertions that only restated the 3x-ui-server- prefix constant; TestFetchSendsStableHwid still goes red without the header. --------- Co-authored-by: sdhfsl Co-authored-by: Sanaei --- internal/sub/external_hwid_test.go | 67 +++++++++++++++++++++++++++ internal/sub/external_subscription.go | 47 +++++++++++++++++-- 2 files changed, 110 insertions(+), 4 deletions(-) create mode 100644 internal/sub/external_hwid_test.go diff --git a/internal/sub/external_hwid_test.go b/internal/sub/external_hwid_test.go new file mode 100644 index 000000000..ad0e210be --- /dev/null +++ b/internal/sub/external_hwid_test.go @@ -0,0 +1,67 @@ +package sub + +import ( + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +// #6559: the Master panel must send a stable X-HWID when fetching external +// subscriptions, otherwise an HWID-limited donor answers 404. +func TestServerHwidStableAcrossCalls(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() }) + + first := serverHwid() + if first == "" { + t.Fatal("serverHwid returned empty") + } + + second := serverHwid() + if second != first { + t.Fatalf("hwid not stable: %q vs %q", first, second) + } + + var row model.Setting + if err := database.GetDB().Where("key = ?", serverHwidKey).First(&row).Error; err != nil { + t.Fatalf("hwid not persisted: %v", err) + } + if row.Value != first { + t.Fatalf("persisted hwid %q != returned %q", row.Value, first) + } +} + +// The fetch must carry the stable id so an HWID-limited donor lets it through. +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()) + } +} diff --git a/internal/sub/external_subscription.go b/internal/sub/external_subscription.go index 862cbb496..e5aeb8682 100644 --- a/internal/sub/external_subscription.go +++ b/internal/sub/external_subscription.go @@ -9,15 +9,15 @@ import ( "sync" "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 @@ -150,6 +150,10 @@ 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). + if hwid := serverHwid(); hwid != "" { + req.Header.Set("X-HWID", hwid) + } resp, err := subscriptionHTTPClient.Do(req) if err != nil { return nil, err @@ -173,6 +177,41 @@ 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. +const serverHwidKey = "externalSubHwid" + +// 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 "" + } + var row model.Setting + if err := db.Where("key = ?", serverHwidKey).First(&row).Error; err == nil { + if strings.TrimSpace(row.Value) != "" { + return strings.TrimSpace(row.Value) + } + } + hwid := "3x-ui-server-" + uuid.NewString() + row = model.Setting{Key: serverHwidKey, Value: hwid} + if err := db.Where(model.Setting{Key: serverHwidKey}).FirstOrCreate(&row).Error; err != nil { + logger.Warningf("sub: persisting server hwid failed: %v", err) + return "" + } + if strings.TrimSpace(row.Value) == "" { + return hwid + } + return strings.TrimSpace(row.Value) +} + type subError struct{ msg string } func (e *subError) Error() string { return e.msg }