From a2ca02333612593ed5f68994e85e5706e57931b4 Mon Sep 17 00:00:00 2001 From: sdhfsl Date: Sun, 27 Sep 2026 03:59:29 +0800 Subject: [PATCH] fix(sub): send panel guid as X-HWID on outbound subscription fetch (#6579) * 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 Co-authored-by: MHSanaei --- internal/sub/external_hwid_test.go | 35 +---------- internal/sub/external_subscription.go | 40 +------------ internal/web/service/external_hwid.go | 45 ++++++++++++++ internal/web/service/outbound_hwid_test.go | 60 +++++++++++++++++++ internal/web/service/outbound_subscription.go | 4 ++ 5 files changed, 114 insertions(+), 70 deletions(-) create mode 100644 internal/web/service/external_hwid.go create mode 100644 internal/web/service/outbound_hwid_test.go diff --git a/internal/sub/external_hwid_test.go b/internal/sub/external_hwid_test.go index ad0e210be..702f5abd7 100644 --- a/internal/sub/external_hwid_test.go +++ b/internal/sub/external_hwid_test.go @@ -7,37 +7,11 @@ import ( "testing" "github.com/mhsanaei/3x-ui/v3/internal/database" - "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/web/service" ) // #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) @@ -58,10 +32,7 @@ func TestFetchSendsStableHwid(t *testing.T) { 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 want := service.ExternalSubscriptionHwid(); gotHwid == "" || gotHwid != want { + t.Fatalf("X-HWID = %q, want the panel's stable %q", gotHwid, want) } } diff --git a/internal/sub/external_subscription.go b/internal/sub/external_subscription.go index e5aeb8682..e6122edcf 100644 --- a/internal/sub/external_subscription.go +++ b/internal/sub/external_subscription.go @@ -9,11 +9,10 @@ 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" + "github.com/mhsanaei/3x-ui/v3/internal/web/service" ) // External subscription fetching: a remote URL whose body is a share-link @@ -151,7 +150,7 @@ 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 != "" { + if hwid := service.ExternalSubscriptionHwid(); hwid != "" { req.Header.Set("X-HWID", hwid) } resp, err := subscriptionHTTPClient.Do(req) @@ -177,41 +176,6 @@ 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 } diff --git a/internal/web/service/external_hwid.go b/internal/web/service/external_hwid.go new file mode 100644 index 000000000..4eeae3f9e --- /dev/null +++ b/internal/web/service/external_hwid.go @@ -0,0 +1,45 @@ +package service + +import ( + "strings" + "sync" + + "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" +) + +const externalSubHwidKey = "externalSubHwid" + +// externalSubHwidMu serializes first-time creation: without it, concurrent first +// fetches each mint and persist their own id. +var externalSubHwidMu sync.Mutex + +// ExternalSubscriptionHwid is the X-HWID every subscription fetch sends, so an +// HWID-limited provider counts this panel as one device. Empty: DB unreachable. +func ExternalSubscriptionHwid() string { + externalSubHwidMu.Lock() + defer externalSubHwidMu.Unlock() + db := database.GetDB() + if db == nil { + return "" + } + var row model.Setting + if err := db.Where("key = ?", externalSubHwidKey).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: externalSubHwidKey, Value: hwid} + if err := db.Where(model.Setting{Key: externalSubHwidKey}).FirstOrCreate(&row).Error; err != nil { + logger.Warningf("persisting the external subscription hwid failed: %v", err) + return "" + } + if strings.TrimSpace(row.Value) == "" { + return hwid + } + return strings.TrimSpace(row.Value) +} diff --git a/internal/web/service/outbound_hwid_test.go b/internal/web/service/outbound_hwid_test.go new file mode 100644 index 000000000..e1478a371 --- /dev/null +++ b/internal/web/service/outbound_hwid_test.go @@ -0,0 +1,60 @@ +package service + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +// #6574: an outbound subscription fetch must send the id client external links +// already send (#6559), or an HWID-limited provider counts the panel twice. +func TestOutboundFetchSendsExternalSubscriptionHwid(t *testing.T) { + setupSettingTestDB(t) + + var gotHwid string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotHwid = r.Header.Get("X-HWID") + _, _ = w.Write([]byte("outbounds:\n")) + })) + defer srv.Close() + + svc := NewOutboundSubscriptionService() + sub, err := svc.Create("hwid-test", srv.URL, "", "", true, 3600, true, false, false) + if err != nil { + t.Fatalf("create: %v", err) + } + t.Cleanup(func() { _ = svc.Delete(sub.Id) }) + + if _, err := svc.Refresh(sub.Id); err != nil { + t.Fatalf("refresh: %v", err) + } + var row model.Setting + if err := database.GetDB().Where("key = ?", "externalSubHwid").First(&row).Error; err != nil { + t.Fatalf("no persisted externalSubHwid after the fetch: %v", err) + } + if gotHwid == "" || gotHwid != row.Value { + t.Fatalf("X-HWID = %q, want the persisted externalSubHwid %q", gotHwid, row.Value) + } +} + +func TestExternalSubscriptionHwidIsStableAndPersisted(t *testing.T) { + setupSettingTestDB(t) + + first := ExternalSubscriptionHwid() + if first == "" { + t.Fatal("ExternalSubscriptionHwid returned empty") + } + if second := ExternalSubscriptionHwid(); second != first { + t.Fatalf("hwid not stable: %q vs %q", first, second) + } + var row model.Setting + if err := database.GetDB().Where("key = ?", "externalSubHwid").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) + } +} diff --git a/internal/web/service/outbound_subscription.go b/internal/web/service/outbound_subscription.go index 5384909bb..47f4c90fc 100644 --- a/internal/web/service/outbound_subscription.go +++ b/internal/web/service/outbound_subscription.go @@ -372,6 +372,10 @@ func (s *OutboundSubscriptionService) fetchAndStore(sub *model.OutboundSubscript userAgent = defaultOutboundSubscriptionUserAgent } req.Header.Set("User-Agent", userAgent) + // A 3x-ui donor with an HWID limit answers 404 when the header is empty (#6574). + if hwid := ExternalSubscriptionHwid(); hwid != "" { + req.Header.Set("X-HWID", hwid) + } resp, err := client.Do(req) if err != nil {