mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-16 00:01:02 +00:00
feat(sub): add per-client subscription HWID limits (#5802)
* feat(sub): add per-client subscription HWID limits * fix(sub): address HWID review on shared subId and bulk create * fix(sub): store HWID devices by sub_id and drop anchor client workaround * fix(sub): restore UA auto-detect and HTML page routing in subs() The cherry-pick of the HWID gate onto main's refactored SUBController had dropped main's UA-based format auto-detection and sub-page handling from subs(). Restore those branches, slotting enforceHwid after the HTML page and before format detection so the gate only applies to machine-readable subscription bodies. Also adapt tests to main's options-struct constructor and to the ClientService.Update signature extended with limitHwid. * fix(frontend): drop axios from HttpUtil.delete The bulk-delete rework's committed version still referenced axios, which this file no longer imports, breaking typecheck in CI. Use the httpRequest wrapper like the other verbs. --------- Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
@@ -113,7 +113,7 @@ func TestAllAPIsPostgresScale(t *testing.T) {
|
||||
run("UpdateByEmail", func() error {
|
||||
upd := clients[n/3]
|
||||
upd.Comment = "touched"
|
||||
_, err := svc.UpdateByEmail(inboundSvc, upd.Email, upd)
|
||||
_, err := svc.UpdateByEmail(inboundSvc, upd.Email, upd, 0)
|
||||
return err
|
||||
})
|
||||
run("AttachByEmail", func() error { _, err := svc.AttachByEmail(inboundSvc, emails[n/3], []int{ib2.Id}); return err })
|
||||
|
||||
@@ -68,6 +68,36 @@ var ErrClientNotInInbound = errors.New("client not found in inbound")
|
||||
type ClientCreatePayload struct {
|
||||
Client model.Client `json:"client"`
|
||||
InboundIds []int `json:"inboundIds"`
|
||||
LimitHwid int `json:"-"`
|
||||
}
|
||||
|
||||
const sqlInChunk = 400
|
||||
|
||||
type clientPayloadWithHwid struct {
|
||||
model.Client
|
||||
LimitHwid int `json:"limitHwid"`
|
||||
}
|
||||
|
||||
func (p *ClientCreatePayload) UnmarshalJSON(data []byte) error {
|
||||
var raw struct {
|
||||
Client clientPayloadWithHwid `json:"client"`
|
||||
InboundIds []int `json:"inboundIds"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
p.Client = raw.Client.Client
|
||||
p.InboundIds = raw.InboundIds
|
||||
p.LimitHwid = raw.Client.LimitHwid
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p ClientCreatePayload) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Client clientPayloadWithHwid `json:"client"`
|
||||
InboundIds []int `json:"inboundIds"`
|
||||
}{
|
||||
Client: clientPayloadWithHwid{Client: p.Client, LimitHwid: p.LimitHwid},
|
||||
InboundIds: p.InboundIds,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -816,6 +816,7 @@ func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string,
|
||||
successEmails := make([]string, 0, len(recordsByEmail))
|
||||
successIds := make([]int, 0, len(recordsByEmail))
|
||||
failedEmails := make([]string, 0, len(recordsByEmail))
|
||||
successSubIDs := make([]string, 0, len(recordsByEmail))
|
||||
for email, rec := range recordsByEmail {
|
||||
if _, skipped := skippedReasons[email]; skipped {
|
||||
failedEmails = append(failedEmails, email)
|
||||
@@ -823,6 +824,7 @@ func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string,
|
||||
}
|
||||
successEmails = append(successEmails, email)
|
||||
successIds = append(successIds, rec.Id)
|
||||
successSubIDs = append(successSubIDs, rec.SubID)
|
||||
}
|
||||
withdrawClientTombstones(failedEmails...)
|
||||
|
||||
@@ -833,6 +835,9 @@ func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string,
|
||||
if e := adjustGroupBaselinesForRemovedTraffic(tx, successEmails); e != nil {
|
||||
return e
|
||||
}
|
||||
if e := clearClientHwidsBySubIDTx(tx, successSubIDs...); e != nil {
|
||||
return e
|
||||
}
|
||||
for _, batch := range chunkInts(successIds, sqlInChunk) {
|
||||
if e := tx.Where("client_id IN ?", batch).Delete(&model.ClientInbound{}).Error; e != nil {
|
||||
return e
|
||||
@@ -1119,6 +1124,7 @@ func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []Client
|
||||
type prepared struct {
|
||||
client model.Client
|
||||
inboundIds []int
|
||||
limitHwid int
|
||||
}
|
||||
prep := make([]prepared, 0, len(payloads))
|
||||
emails := make([]string, 0, len(payloads))
|
||||
@@ -1171,7 +1177,7 @@ func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []Client
|
||||
seenEmail[le] = struct{}{}
|
||||
seenSubID[client.SubID] = le
|
||||
|
||||
prep = append(prep, prepared{client: client, inboundIds: payloads[i].InboundIds})
|
||||
prep = append(prep, prepared{client: client, inboundIds: payloads[i].InboundIds, limitHwid: payloads[i].LimitHwid})
|
||||
emails = append(emails, email)
|
||||
subIDs = append(subIDs, client.SubID)
|
||||
}
|
||||
@@ -1303,9 +1309,13 @@ func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []Client
|
||||
for idx := range prep {
|
||||
if failed[idx] {
|
||||
skip(prep[idx].client.Email, reason[idx])
|
||||
} else {
|
||||
result.Created++
|
||||
continue
|
||||
}
|
||||
if err := s.setClientLimitHwidByEmail(nil, prep[idx].client.Email, prep[idx].limitHwid); err != nil {
|
||||
skip(prep[idx].client.Email, err.Error())
|
||||
continue
|
||||
}
|
||||
result.Created++
|
||||
}
|
||||
return result, needRestart, nil
|
||||
}
|
||||
|
||||
@@ -140,6 +140,9 @@ func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreate
|
||||
needRestart = true
|
||||
}
|
||||
}
|
||||
if err := s.setClientLimitHwidByEmail(nil, client.Email, payload.LimitHwid); err != nil {
|
||||
return needRestart, err
|
||||
}
|
||||
return needRestart, nil
|
||||
}
|
||||
|
||||
@@ -309,7 +312,7 @@ func applyShadowsocksClientMethod(clients []any, settings map[string]any) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model.Client, inboundFilter ...int) (bool, error) {
|
||||
func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model.Client, limitHwid int, inboundFilter ...int) (bool, error) {
|
||||
existing, err := s.GetByID(id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -507,6 +510,10 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
|
||||
return needRestart, err
|
||||
}
|
||||
|
||||
if err := s.setClientLimitHwidByEmail(nil, updated.Email, limitHwid); err != nil {
|
||||
return needRestart, err
|
||||
}
|
||||
|
||||
if err := database.GetDB().Model(&model.ClientRecord{}).
|
||||
Where("id = ?", id).
|
||||
UpdateColumn("updated_at", time.Now().UnixMilli()).Error; err != nil {
|
||||
@@ -581,6 +588,9 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b
|
||||
if err := tx.Where("client_id = ?", id).Delete(&model.ClientExternalLink{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := clearClientHwidsBySubIDTx(tx, existing.SubID); err != nil {
|
||||
return err
|
||||
}
|
||||
if !keepTraffic && existing.Email != "" {
|
||||
if err := tx.Where("email = ?", existing.Email).Delete(&xray.ClientTraffic{}).Error; err != nil {
|
||||
return err
|
||||
@@ -755,7 +765,7 @@ func (s *ClientService) DeleteByEmail(inboundSvc *InboundService, email string,
|
||||
return needRestart, nil
|
||||
}
|
||||
|
||||
func (s *ClientService) UpdateByEmail(inboundSvc *InboundService, email string, updated model.Client, inboundFilter ...int) (bool, error) {
|
||||
func (s *ClientService) UpdateByEmail(inboundSvc *InboundService, email string, updated model.Client, limitHwid int, inboundFilter ...int) (bool, error) {
|
||||
if email == "" {
|
||||
return false, common.NewError("client email is required")
|
||||
}
|
||||
@@ -763,7 +773,7 @@ func (s *ClientService) UpdateByEmail(inboundSvc *InboundService, email string,
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return s.Update(inboundSvc, rec.Id, updated, inboundFilter...)
|
||||
return s.Update(inboundSvc, rec.Id, updated, limitHwid, inboundFilter...)
|
||||
}
|
||||
|
||||
func (s *ClientService) Detach(inboundSvc *InboundService, id int, inboundIds []int) (bool, error) {
|
||||
|
||||
@@ -171,7 +171,7 @@ func TestClientUpdate_ClearsGroup(t *testing.T) {
|
||||
// Edit the client and remove the group.
|
||||
updated := *rec.ToClient()
|
||||
updated.Group = ""
|
||||
if _, err := svc.Update(inboundSvc, rec.Id, updated); err != nil {
|
||||
if _, err := svc.Update(inboundSvc, rec.Id, updated, 0); err != nil {
|
||||
t.Fatalf("Update (clear group): %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type HwidRequest struct {
|
||||
Hwid string
|
||||
UserAgent string
|
||||
DeviceOS string
|
||||
OsVersion string
|
||||
DeviceModel string
|
||||
}
|
||||
|
||||
type HwidGateResult struct {
|
||||
Allowed bool
|
||||
Active bool
|
||||
NotSupported bool
|
||||
MaxDevicesReached bool
|
||||
LimitReached bool
|
||||
Limit int
|
||||
Registered int
|
||||
}
|
||||
|
||||
const minHwidLength = 6
|
||||
|
||||
type ClientHwidInfo struct {
|
||||
Id int `json:"id"`
|
||||
FirstSeen int64 `json:"firstSeen"`
|
||||
LastSeen int64 `json:"lastSeen"`
|
||||
UserAgent string `json:"userAgent"`
|
||||
DeviceOS string `json:"deviceOs"`
|
||||
OsVersion string `json:"osVersion"`
|
||||
DeviceModel string `json:"deviceModel"`
|
||||
}
|
||||
|
||||
func hashHwid(raw string) string {
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func trimHwidMeta(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
r := []rune(s)
|
||||
if len(r) > 512 {
|
||||
return string(r[:512])
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func normalizeHwidRequest(req HwidRequest) HwidRequest {
|
||||
return HwidRequest{
|
||||
Hwid: strings.TrimSpace(req.Hwid),
|
||||
UserAgent: trimHwidMeta(req.UserAgent),
|
||||
DeviceOS: trimHwidMeta(req.DeviceOS),
|
||||
OsVersion: trimHwidMeta(req.OsVersion),
|
||||
DeviceModel: trimHwidMeta(req.DeviceModel),
|
||||
}
|
||||
}
|
||||
|
||||
func effectiveHwidLimitForSubID(tx *gorm.DB, subID string) (int, error) {
|
||||
var limit int
|
||||
err := tx.Model(&model.ClientRecord{}).
|
||||
Where("sub_id = ? AND enable = ?", subID, true).
|
||||
Select("COALESCE(MAX(limit_hwid), 0)").
|
||||
Scan(&limit).Error
|
||||
return limit, err
|
||||
}
|
||||
|
||||
func (s *ClientService) EnforceHwidForSubID(subID string, req HwidRequest) (HwidGateResult, error) {
|
||||
var res HwidGateResult
|
||||
subID = strings.TrimSpace(subID)
|
||||
if subID == "" {
|
||||
res.Allowed = true
|
||||
return res, nil
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
limit, err := effectiveHwidLimitForSubID(db, subID)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
if limit <= 0 {
|
||||
res.Allowed = true
|
||||
return res, nil
|
||||
}
|
||||
|
||||
req = normalizeHwidRequest(req)
|
||||
res.Active = true
|
||||
res.Limit = limit
|
||||
if len(req.Hwid) < minHwidLength {
|
||||
res.NotSupported = true
|
||||
return res, nil
|
||||
}
|
||||
hwidHash := hashHwid(req.Hwid)
|
||||
|
||||
err = db.Transaction(func(tx *gorm.DB) error {
|
||||
limit, err := effectiveHwidLimitForSubID(tx, subID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if limit <= 0 {
|
||||
res = HwidGateResult{Allowed: true}
|
||||
return nil
|
||||
}
|
||||
res.Active = true
|
||||
res.Limit = limit
|
||||
now := time.Now().UnixMilli()
|
||||
var existing model.ClientHwid
|
||||
err = tx.Where("sub_id = ? AND hwid_hash = ?", subID, hwidHash).First(&existing).Error
|
||||
if err == nil {
|
||||
if err := tx.Model(&model.ClientHwid{}).Where("id = ?", existing.Id).Updates(map[string]any{
|
||||
"last_seen": now, "user_agent": req.UserAgent, "device_os": req.DeviceOS, "os_version": req.OsVersion, "device_model": req.DeviceModel,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var count int64
|
||||
if err := tx.Model(&model.ClientHwid{}).Where("sub_id = ?", subID).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
res.Allowed = true
|
||||
res.Registered = int(count)
|
||||
res.LimitReached = count >= int64(limit)
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
var count int64
|
||||
if err := tx.Model(&model.ClientHwid{}).Where("sub_id = ?", subID).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
res.Registered = int(count)
|
||||
if count >= int64(limit) {
|
||||
res.MaxDevicesReached = true
|
||||
res.LimitReached = true
|
||||
return nil
|
||||
}
|
||||
if err := tx.Create(&model.ClientHwid{SubID: subID, HwidHash: hwidHash, FirstSeen: now, LastSeen: now, UserAgent: req.UserAgent, DeviceOS: req.DeviceOS, OsVersion: req.OsVersion, DeviceModel: req.DeviceModel}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
res.Allowed = true
|
||||
res.Registered = int(count) + 1
|
||||
res.LimitReached = res.Registered >= limit
|
||||
return nil
|
||||
})
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (s *ClientService) ListClientHwids(email string) ([]ClientHwidInfo, error) {
|
||||
rec, err := s.GetRecordByEmail(nil, email)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
subID := strings.TrimSpace(rec.SubID)
|
||||
if subID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var rows []model.ClientHwid
|
||||
if err := database.GetDB().
|
||||
Where("sub_id = ?", subID).
|
||||
Order("last_seen DESC").
|
||||
Order("id DESC").
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ClientHwidInfo, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, ClientHwidInfo{
|
||||
Id: r.Id,
|
||||
FirstSeen: r.FirstSeen,
|
||||
LastSeen: r.LastSeen,
|
||||
UserAgent: r.UserAgent,
|
||||
DeviceOS: r.DeviceOS,
|
||||
OsVersion: r.OsVersion,
|
||||
DeviceModel: r.DeviceModel,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ClientService) ClearClientHwids(email string) error {
|
||||
rec, err := s.GetRecordByEmail(nil, email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
subID := strings.TrimSpace(rec.SubID)
|
||||
if subID == "" {
|
||||
return nil
|
||||
}
|
||||
return database.GetDB().Where("sub_id = ?", subID).Delete(&model.ClientHwid{}).Error
|
||||
}
|
||||
|
||||
func (s *ClientService) setClientLimitHwidByEmail(tx *gorm.DB, email string, limit int) error {
|
||||
if tx == nil {
|
||||
tx = database.GetDB()
|
||||
}
|
||||
if limit < 0 {
|
||||
limit = 0
|
||||
}
|
||||
var rec model.ClientRecord
|
||||
if err := tx.Where("email = ?", email).First(&rec).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&model.ClientRecord{}).Where("id = ?", rec.Id).UpdateColumn("limit_hwid", limit).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
subID := strings.TrimSpace(rec.SubID)
|
||||
if subID == "" {
|
||||
return nil
|
||||
}
|
||||
effective, err := effectiveHwidLimitForSubID(tx, subID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return trimClientHwidsForSubID(tx, subID, effective)
|
||||
}
|
||||
|
||||
func trimClientHwidsForSubID(tx *gorm.DB, subID string, limit int) error {
|
||||
subID = strings.TrimSpace(subID)
|
||||
if subID == "" || limit <= 0 {
|
||||
return nil
|
||||
}
|
||||
var keep []int
|
||||
if err := tx.Model(&model.ClientHwid{}).
|
||||
Where("sub_id = ?", subID).
|
||||
Order("last_seen DESC").
|
||||
Order("id DESC").
|
||||
Limit(limit).
|
||||
Pluck("id", &keep).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(keep) == 0 {
|
||||
return tx.Where("sub_id = ?", subID).Delete(&model.ClientHwid{}).Error
|
||||
}
|
||||
return tx.Where("sub_id = ? AND id NOT IN ?", subID, keep).Delete(&model.ClientHwid{}).Error
|
||||
}
|
||||
|
||||
func clearClientHwidsBySubIDTx(tx *gorm.DB, subIDs ...string) error {
|
||||
if tx == nil {
|
||||
tx = database.GetDB()
|
||||
}
|
||||
clean := make([]string, 0, len(subIDs))
|
||||
seen := map[string]struct{}{}
|
||||
for _, subID := range subIDs {
|
||||
subID = strings.TrimSpace(subID)
|
||||
if subID == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[subID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[subID] = struct{}{}
|
||||
clean = append(clean, subID)
|
||||
}
|
||||
for _, batch := range chunkStrings(clean, sqlInChunk) {
|
||||
if err := tx.Where("sub_id IN ?", batch).Delete(&model.ClientHwid{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func initClientHwidTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
dbDir := t.TempDir()
|
||||
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
}
|
||||
|
||||
func seedHwidClient(t *testing.T, limit int) *model.ClientRecord {
|
||||
t.Helper()
|
||||
rec := &model.ClientRecord{
|
||||
Email: "hwid@example.com",
|
||||
SubID: "sub-hwid",
|
||||
UUID: "11111111-2222-4333-8444-555555555555",
|
||||
Enable: true,
|
||||
LimitHwid: limit,
|
||||
}
|
||||
if err := database.GetDB().Create(rec).Error; err != nil {
|
||||
t.Fatalf("seed client: %v", err)
|
||||
}
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestClientHwidGate(t *testing.T) {
|
||||
initClientHwidTestDB(t)
|
||||
svc := &ClientService{}
|
||||
|
||||
seedHwidClient(t, 0)
|
||||
res, err := svc.EnforceHwidForSubID("sub-hwid", HwidRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("no-limit gate: %v", err)
|
||||
}
|
||||
if !res.Allowed || res.Active {
|
||||
t.Fatalf("no limit should allow missing HWID without active headers: %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientHwidGateRegistersAndBlocks(t *testing.T) {
|
||||
initClientHwidTestDB(t)
|
||||
svc := &ClientService{}
|
||||
rec := seedHwidClient(t, 2)
|
||||
|
||||
res, err := svc.EnforceHwidForSubID(rec.SubID, HwidRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("missing HWID gate: %v", err)
|
||||
}
|
||||
if res.Allowed || !res.Active || !res.NotSupported {
|
||||
t.Fatalf("missing HWID should be denied as not supported: %+v", res)
|
||||
}
|
||||
|
||||
firstRaw := "device-one"
|
||||
for _, raw := range []string{firstRaw, "device-two"} {
|
||||
res, err = svc.EnforceHwidForSubID(rec.SubID, HwidRequest{
|
||||
Hwid: raw,
|
||||
UserAgent: "Happ/1.0",
|
||||
DeviceOS: "android",
|
||||
OsVersion: "15",
|
||||
DeviceModel: raw + "-model",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("register %s: %v", raw, err)
|
||||
}
|
||||
if !res.Allowed {
|
||||
t.Fatalf("register %s denied: %+v", raw, res)
|
||||
}
|
||||
}
|
||||
|
||||
res, err = svc.EnforceHwidForSubID(rec.SubID, HwidRequest{Hwid: "device-three"})
|
||||
if err != nil {
|
||||
t.Fatalf("third HWID gate: %v", err)
|
||||
}
|
||||
if res.Allowed || !res.MaxDevicesReached || !res.LimitReached {
|
||||
t.Fatalf("third unique HWID should be denied after limit: %+v", res)
|
||||
}
|
||||
|
||||
res, err = svc.EnforceHwidForSubID(rec.SubID, HwidRequest{
|
||||
Hwid: firstRaw,
|
||||
UserAgent: "Karing/2.0",
|
||||
DeviceOS: "ios",
|
||||
OsVersion: "18",
|
||||
DeviceModel: "updated-model",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("existing HWID after full limit: %v", err)
|
||||
}
|
||||
if !res.Allowed || !res.LimitReached {
|
||||
t.Fatalf("existing registered HWID should pass after limit: %+v", res)
|
||||
}
|
||||
|
||||
var hashes []string
|
||||
if err := database.GetDB().Model(&model.ClientHwid{}).Pluck("hwid_hash", &hashes).Error; err != nil {
|
||||
t.Fatalf("pluck hashes: %v", err)
|
||||
}
|
||||
if len(hashes) != 2 {
|
||||
t.Fatalf("stored HWIDs = %d, want 2", len(hashes))
|
||||
}
|
||||
for _, h := range hashes {
|
||||
if h == firstRaw || h == "device-two" || len(h) != 64 {
|
||||
t.Fatalf("raw HWID leaked or invalid hash stored: %q", h)
|
||||
}
|
||||
}
|
||||
|
||||
list, err := svc.ListClientHwids(rec.Email)
|
||||
if err != nil {
|
||||
t.Fatalf("list HWIDs: %v", err)
|
||||
}
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("list count = %d, want 2", len(list))
|
||||
}
|
||||
foundUpdated := false
|
||||
for _, row := range list {
|
||||
if row.DeviceModel == "updated-model" && row.UserAgent == "Karing/2.0" && row.DeviceOS == "ios" && row.OsVersion == "18" {
|
||||
foundUpdated = true
|
||||
}
|
||||
}
|
||||
if !foundUpdated {
|
||||
t.Fatalf("updated HWID metadata missing: %#v", list)
|
||||
}
|
||||
|
||||
if err := svc.setClientLimitHwidByEmail(nil, rec.Email, 1); err != nil {
|
||||
t.Fatalf("lower limit: %v", err)
|
||||
}
|
||||
var count int64
|
||||
if err := database.GetDB().Model(&model.ClientHwid{}).Where("sub_id = ?", rec.SubID).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count after trim: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("lowered limit should trim stored HWIDs to 1, got %d", count)
|
||||
}
|
||||
|
||||
if err := svc.ClearClientHwids(rec.Email); err != nil {
|
||||
t.Fatalf("clear HWIDs: %v", err)
|
||||
}
|
||||
if err := database.GetDB().Model(&model.ClientHwid{}).Where("sub_id = ?", rec.SubID).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count after clear: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("clear should remove all HWIDs, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientHwidGateSharedSubIdUsesMaxLimit(t *testing.T) {
|
||||
initClientHwidTestDB(t)
|
||||
svc := &ClientService{}
|
||||
db := database.GetDB()
|
||||
subID := "shared-sub"
|
||||
if err := db.Create(&model.ClientRecord{Email: "a@ex.com", SubID: subID, UUID: "11111111-2222-4333-8444-555555555555", Enable: true, LimitHwid: 0}).Error; err != nil {
|
||||
t.Fatalf("seed anchor: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.ClientRecord{Email: "b@ex.com", SubID: subID, UUID: "22222222-2222-4333-8444-555555555555", Enable: true, LimitHwid: 2}).Error; err != nil {
|
||||
t.Fatalf("seed second: %v", err)
|
||||
}
|
||||
res, err := svc.EnforceHwidForSubID(subID, HwidRequest{})
|
||||
if err != nil || !res.Active || res.Limit != 2 {
|
||||
t.Fatalf("expected active gate limit 2 from max row, err=%v res=%+v", err, res)
|
||||
}
|
||||
if res.Allowed || !res.NotSupported {
|
||||
t.Fatalf("missing HWID should be denied: %+v", res)
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ type ClientSlim struct {
|
||||
TotalGB int64 `json:"totalGB"`
|
||||
ExpiryTime int64 `json:"expiryTime"`
|
||||
LimitIP int `json:"limitIp"`
|
||||
LimitHwid int `json:"limitHwid"`
|
||||
Reset int `json:"reset"`
|
||||
Group string `json:"group,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
@@ -457,21 +458,11 @@ func (q clientQuery) pageRows(params ClientPageParams, onlines []string, offset,
|
||||
if rec == nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, ClientSlim{
|
||||
Email: rec.Email,
|
||||
SubID: rec.SubID,
|
||||
Enable: rec.Enable,
|
||||
TotalGB: rec.TotalGB,
|
||||
ExpiryTime: rec.ExpiryTime,
|
||||
LimitIP: rec.LimitIP,
|
||||
Reset: rec.Reset,
|
||||
Group: rec.Group,
|
||||
Comment: rec.Comment,
|
||||
InboundIds: attachments[rec.Id],
|
||||
Traffic: trafficByEmail[rec.Email],
|
||||
CreatedAt: rec.CreatedAt,
|
||||
UpdatedAt: rec.UpdatedAt,
|
||||
})
|
||||
items = append(items, toClientSlim(ClientWithAttachments{
|
||||
ClientRecord: *rec,
|
||||
InboundIds: attachments[rec.Id],
|
||||
Traffic: trafficByEmail[rec.Email],
|
||||
}))
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -604,6 +595,25 @@ func sqlInt(v int64) string {
|
||||
return strconv.FormatInt(v, 10)
|
||||
}
|
||||
|
||||
func toClientSlim(c ClientWithAttachments) ClientSlim {
|
||||
return ClientSlim{
|
||||
Email: c.Email,
|
||||
SubID: c.SubID,
|
||||
Enable: c.Enable,
|
||||
TotalGB: c.TotalGB,
|
||||
ExpiryTime: c.ExpiryTime,
|
||||
LimitIP: c.LimitIP,
|
||||
LimitHwid: c.LimitHwid,
|
||||
Reset: c.Reset,
|
||||
Group: c.Group,
|
||||
Comment: c.Comment,
|
||||
InboundIds: c.InboundIds,
|
||||
Traffic: c.Traffic,
|
||||
CreatedAt: c.CreatedAt,
|
||||
UpdatedAt: c.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// escapeLikeLiteral neutralises LIKE wildcards so searching for "a_b" keeps
|
||||
// matching literally, the way strings.Contains did.
|
||||
func escapeLikeLiteral(s string) string {
|
||||
|
||||
@@ -54,6 +54,7 @@ func (s *ClientService) ExportAll() ([]ClientCreatePayload, error) {
|
||||
out = append(out, ClientCreatePayload{
|
||||
Client: *client,
|
||||
InboundIds: attachments[rows[i].Id],
|
||||
LimitHwid: rows[i].LimitHwid,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
@@ -151,7 +152,9 @@ func (s *ClientService) ImportClients(inboundSvc *InboundService, items []Client
|
||||
}
|
||||
client.UpdatedAt = now
|
||||
|
||||
if err := db.Create(client.ToRecord()).Error; err != nil {
|
||||
rec := client.ToRecord()
|
||||
rec.LimitHwid = orphans[i].LimitHwid
|
||||
if err := db.Create(rec).Error; err != nil {
|
||||
skip(email, err.Error())
|
||||
continue
|
||||
}
|
||||
@@ -178,11 +181,13 @@ func (s *ClientService) DeleteOrphans() (int, error) {
|
||||
|
||||
ids := make([]int, 0, len(rows))
|
||||
emails := make([]string, 0, len(rows))
|
||||
subIDs := make([]string, 0, len(rows))
|
||||
for i := range rows {
|
||||
ids = append(ids, rows[i].Id)
|
||||
if rows[i].Email != "" {
|
||||
emails = append(emails, rows[i].Email)
|
||||
}
|
||||
subIDs = append(subIDs, rows[i].SubID)
|
||||
}
|
||||
tombstoneClientEmails(emails)
|
||||
|
||||
@@ -190,6 +195,9 @@ func (s *ClientService) DeleteOrphans() (int, error) {
|
||||
if e := adjustGroupBaselinesForRemovedTraffic(tx, emails); e != nil {
|
||||
return e
|
||||
}
|
||||
if e := clearClientHwidsBySubIDTx(tx, subIDs...); e != nil {
|
||||
return e
|
||||
}
|
||||
for _, batch := range chunkInts(ids, sqlInChunk) {
|
||||
if e := tx.Where("client_id IN ?", batch).Delete(&model.ClientInbound{}).Error; e != nil {
|
||||
return e
|
||||
|
||||
@@ -43,7 +43,7 @@ func TestAddClientStat_RefreshesStaleRowOnInboundDeleteThenReuse(t *testing.T) {
|
||||
if _, err := svc.Update(inboundSvc, rec0.Id, model.Client{
|
||||
Email: email, SubID: subID, Enable: false,
|
||||
TotalGB: 0, ExpiryTime: 1000, Reset: 0,
|
||||
}); err != nil {
|
||||
}, 0); err != nil {
|
||||
t.Fatalf("Update to disabled: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ func (s *ClientService) ResetTrafficByEmail(inboundSvc *InboundService, email st
|
||||
if !rec.Enable {
|
||||
updated := rec.ToClient()
|
||||
updated.Enable = true
|
||||
nr, uErr := s.Update(inboundSvc, rec.Id, *updated)
|
||||
nr, uErr := s.Update(inboundSvc, rec.Id, *updated, rec.LimitHwid)
|
||||
if uErr != nil {
|
||||
logger.Warning("Failed to auto-enable client during traffic reset:", uErr)
|
||||
}
|
||||
@@ -84,7 +84,7 @@ func (s *ClientService) BulkResetTraffic(inboundSvc *InboundService, emails []st
|
||||
if err == nil && !rec.Enable {
|
||||
updated := rec.ToClient()
|
||||
updated.Enable = true
|
||||
if _, uErr := s.Update(inboundSvc, rec.Id, *updated); uErr != nil {
|
||||
if _, uErr := s.Update(inboundSvc, rec.Id, *updated, rec.LimitHwid); uErr != nil {
|
||||
logger.Warning("Failed to auto-enable client during bulk traffic reset:", uErr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ func TestUpdate_PersistsRecordEnable_True(t *testing.T) {
|
||||
}
|
||||
updated := rec.ToClient()
|
||||
updated.Enable = true
|
||||
if _, err := svc.Update(inboundSvc, rec.Id, *updated); err != nil {
|
||||
if _, err := svc.Update(inboundSvc, rec.Id, *updated, 0); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ func TestUpdate_PersistsRecordEnable_False(t *testing.T) {
|
||||
}
|
||||
updated := rec.ToClient()
|
||||
updated.Enable = false
|
||||
if _, err := svc.Update(inboundSvc, rec.Id, *updated); err != nil {
|
||||
if _, err := svc.Update(inboundSvc, rec.Id, *updated, 0); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ func TestUpdate_PersistsRecordEnable_NoInbound(t *testing.T) {
|
||||
|
||||
updated := rec.ToClient()
|
||||
updated.Enable = true
|
||||
if _, err := svc.Update(inboundSvc, rec.Id, *updated); err != nil {
|
||||
if _, err := svc.Update(inboundSvc, rec.Id, *updated, 0); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ func TestUpdate_PersistsFields_NoInbound(t *testing.T) {
|
||||
|
||||
updated := rec.ToClient()
|
||||
tc.mutate(updated)
|
||||
if _, err := svc.Update(inboundSvc, rec.Id, *updated); err != nil {
|
||||
if _, err := svc.Update(inboundSvc, rec.Id, *updated, rec.LimitHwid); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ func TestUpdate_NoInbound_PreservesCredentialsWhenOmitted(t *testing.T) {
|
||||
updated.Auth = ""
|
||||
updated.Secret = ""
|
||||
updated.Comment = "only comment changed"
|
||||
if _, err := svc.Update(inboundSvc, rec.Id, *updated); err != nil {
|
||||
if _, err := svc.Update(inboundSvc, rec.Id, *updated, rec.LimitHwid); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ func TestUpdateInboundClientCaseOnlyRenameDoesNotDuplicateRecord(t *testing.T) {
|
||||
|
||||
updated := source[0]
|
||||
updated.Email = "Test"
|
||||
if _, err := svc.Update(inboundSvc, origId, updated); err != nil {
|
||||
if _, err := svc.Update(inboundSvc, origId, updated, 0); err != nil {
|
||||
t.Fatalf("Update case-only email: %v", err)
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ func TestClientUpdateDuplicateSubIDDoesNotRenameEmail(t *testing.T) {
|
||||
updated := source[0]
|
||||
updated.Email = "kept@x"
|
||||
updated.SubID = "sub-other"
|
||||
if _, err := svc.Update(inboundSvc, origId, updated); err == nil {
|
||||
if _, err := svc.Update(inboundSvc, origId, updated, 0); err == nil {
|
||||
t.Fatalf("Update with colliding subId succeeded, want error")
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ func TestClientUpdateKeepsSharedSubIDEditable(t *testing.T) {
|
||||
|
||||
updated := source[0]
|
||||
updated.TotalGB = 42
|
||||
if _, err := svc.Update(inboundSvc, first.Id, updated); err != nil {
|
||||
if _, err := svc.Update(inboundSvc, first.Id, updated, 0); err != nil {
|
||||
t.Fatalf("Update of a client whose subId is already shared: %v", err)
|
||||
}
|
||||
if got := lookupClientRecord(t, "a@node").TotalGB; got != 42 {
|
||||
@@ -175,7 +175,7 @@ func TestClientUpdateKeepsSharedSubIDEditable(t *testing.T) {
|
||||
omitted := source[0]
|
||||
omitted.SubID = ""
|
||||
omitted.TotalGB = 43
|
||||
if _, err := svc.Update(inboundSvc, first.Id, omitted); err != nil {
|
||||
if _, err := svc.Update(inboundSvc, first.Id, omitted, 0); err != nil {
|
||||
t.Fatalf("Update with subId omitted entirely: %v", err)
|
||||
}
|
||||
other := lookupClientRecord(t, "b@node")
|
||||
|
||||
Reference in New Issue
Block a user