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 <sdhfsl@users.noreply.github.com>
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
sdhfsl
2026-09-27 03:59:29 +08:00
committed by GitHub
parent 3fe92df7ad
commit a2ca023336
5 changed files with 114 additions and 70 deletions
+45
View File
@@ -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)
}
@@ -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)
}
}
@@ -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 {