Browse Source

fix(clients): list HWID devices when the HWID limit is 0

EnforceHwidForSubID returned before recording anything when a sub had no
limit, so the panel's HWID Devices list stayed empty for every unlimited
client. Devices are now upserted on the (sub_id, hwid_hash) index without
enforcement or X-Hwid-* headers; the write is best-effort and only logs on
failure, so tracking can never deny a subscription nothing restricts.
Sanaei 9 hours ago
parent
commit
040d01c5dc
2 changed files with 36 additions and 1 deletions
  1. 19 1
      internal/web/service/client_hwid.go
  2. 17 0
      internal/web/service/client_hwid_test.go

+ 19 - 1
internal/web/service/client_hwid.go

@@ -9,8 +9,10 @@ import (
 
 	"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"
 
 	"gorm.io/gorm"
+	"gorm.io/gorm/clause"
 )
 
 type HwidRequest struct {
@@ -110,12 +112,15 @@ func (s *ClientService) EnforceHwidForSubID(subID string, req HwidRequest) (Hwid
 	if err != nil {
 		return res, err
 	}
+	req = normalizeHwidRequest(req)
 	if limit <= 0 {
 		res.Allowed = true
+		if len(req.Hwid) >= minHwidLength {
+			trackUnlimitedHwid(db, subID, req)
+		}
 		return res, nil
 	}
 
-	req = normalizeHwidRequest(req)
 	res.Active = true
 	res.Limit = limit
 	if len(req.Hwid) < minHwidLength {
@@ -177,6 +182,19 @@ func (s *ClientService) EnforceHwidForSubID(subID string, req HwidRequest) (Hwid
 	return res, err
 }
 
+// trackUnlimitedHwid lists devices of a sub with no HWID limit in the panel. It is
+// best-effort: a failed write must not deny a subscription nothing restricts.
+func trackUnlimitedHwid(db *gorm.DB, subID string, req HwidRequest) {
+	now := time.Now().UnixMilli()
+	err := db.Clauses(clause.OnConflict{
+		Columns:   []clause.Column{{Name: "sub_id"}, {Name: "hwid_hash"}},
+		DoUpdates: clause.AssignmentColumns([]string{"last_seen", "user_agent", "device_os", "os_version", "device_model"}),
+	}).Create(&model.ClientHwid{SubID: subID, HwidHash: hashHwid(req.Hwid), FirstSeen: now, LastSeen: now, UserAgent: req.UserAgent, DeviceOS: req.DeviceOS, OsVersion: req.OsVersion, DeviceModel: req.DeviceModel}).Error
+	if err != nil {
+		logger.Warning("track HWID for unlimited subscription failed:", err)
+	}
+}
+
 // HwidSlotStatusForSubID is SELECT-only: it must never write client_hwids or
 // last_seen. Enabled-clients scope mirrors the gate, so limit == limit enforced.
 func (s *ClientService) HwidSlotStatusForSubID(subID string) (status HwidSlotStatus, found bool, err error) {

+ 17 - 0
internal/web/service/client_hwid_test.go

@@ -45,6 +45,23 @@ func TestClientHwidGate(t *testing.T) {
 	if !res.Allowed || res.Active {
 		t.Fatalf("no limit should allow missing HWID without active headers: %+v", res)
 	}
+
+	for _, ua := range []string{"Happ/1.0", "Happ/2.0"} {
+		res, err = svc.EnforceHwidForSubID("sub-hwid", HwidRequest{Hwid: "device-one", UserAgent: ua})
+		if err != nil {
+			t.Fatalf("no-limit gate with HWID: %v", err)
+		}
+		if res != (HwidGateResult{Allowed: true}) {
+			t.Fatalf("no limit should allow HWID without active headers: %+v", res)
+		}
+	}
+	list, err := svc.ListClientHwids("[email protected]")
+	if err != nil {
+		t.Fatalf("list HWIDs: %v", err)
+	}
+	if len(list) != 1 || list[0].UserAgent != "Happ/2.0" {
+		t.Fatalf("no limit should still track one device with fresh metadata, got %+v", list)
+	}
 }
 
 func TestClientHwidGateRegistersAndBlocks(t *testing.T) {