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 {