diff --git a/internal/web/service/client_hwid.go b/internal/web/service/client_hwid.go index 690b69316..2a780b8f0 100644 --- a/internal/web/service/client_hwid.go +++ b/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) { diff --git a/internal/web/service/client_hwid_test.go b/internal/web/service/client_hwid_test.go index 5e66a04bf..1d0b6eca9 100644 --- a/internal/web/service/client_hwid_test.go +++ b/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("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) {