Browse Source

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 <[email protected]>
Co-authored-by: Sanaei <[email protected]>
sdhfsl 9 hours ago
parent
commit
d59b77bcdb
2 changed files with 110 additions and 4 deletions
  1. 67 0
      internal/sub/external_hwid_test.go
  2. 43 4
      internal/sub/external_subscription.go

+ 67 - 0
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())
+	}
+}

+ 43 - 4
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 }