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.
This commit is contained in:
Sanaei
2026-09-16 11:50:49 +02:00
parent b78dd82869
commit 040d01c5dc
2 changed files with 36 additions and 1 deletions

View File

@@ -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) {

View File

@@ -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("hwid@example.com")
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) {