From b2762e4d31d775c6d7bcaabb53c79a8f2227dadf Mon Sep 17 00:00:00 2001 From: sdhfsl Date: Wed, 16 Sep 2026 16:28:31 +0800 Subject: [PATCH] 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 --- internal/sub/external_hwid_test.go | 43 +++++++++++++++++++++++++++ internal/sub/external_subscription.go | 37 +++++++++++++++++++++++ 2 files changed, 80 insertions(+) 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..df7249b57 --- /dev/null +++ b/internal/sub/external_hwid_test.go @@ -0,0 +1,43 @@ +package sub + +import ( + "path/filepath" + "strings" + "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") + } + if !strings.HasPrefix(first, "3x-ui-server-") { + t.Fatalf("unexpected hwid format: %q", first) + } + if len(first) < 6 { + t.Fatalf("hwid too short for donor minHwidLength: %q", first) + } + + 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) + } +} diff --git a/internal/sub/external_subscription.go b/internal/sub/external_subscription.go index 862cbb496..8f0cdb559 100644 --- a/internal/sub/external_subscription.go +++ b/internal/sub/external_subscription.go @@ -9,6 +9,7 @@ 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" @@ -150,6 +151,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) + } resp, err := subscriptionHTTPClient.Do(req) if err != nil { return nil, err @@ -173,6 +180,36 @@ 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" + +// 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. +func serverHwid() string { + 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 }