mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 19:25:05 +03:00
f8ceede46f
effectiveHwidLimitForSubID took the limit over enabled clients only. A client switched off for expiry, quota or by hand therefore had no limit at all, while the subscription body is still served for disabled clients, so any number of devices registered and stayed past the renewal. The limit now follows the subId whatever the enable flag, and the gate, the slot status and the trim keep sharing that one query.
390 lines
14 KiB
Go
390 lines
14 KiB
Go
package service
|
|
|
|
import (
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
|
|
"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)
|
|
dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// The gate runs before the subscription lookup, so a subId no enabled client
|
|
// owns must pass through without leaving a client_hwids row behind.
|
|
func TestClientHwidGateDoesNotTrackUnknownSubID(t *testing.T) {
|
|
initClientHwidTestDB(t)
|
|
svc := &ClientService{}
|
|
db := database.GetDB()
|
|
rec := seedHwidClient(t, 0)
|
|
|
|
countRows := func(subID string) int64 {
|
|
t.Helper()
|
|
var rows int64
|
|
if err := db.Model(&model.ClientHwid{}).Where("sub_id = ?", subID).Count(&rows).Error; err != nil {
|
|
t.Fatalf("count rows for %q: %v", subID, err)
|
|
}
|
|
return rows
|
|
}
|
|
|
|
res, err := svc.EnforceHwidForSubID("no-such-sub", HwidRequest{Hwid: "device-ghost", UserAgent: "Happ/1.0"})
|
|
if err != nil || res != (HwidGateResult{Allowed: true}) {
|
|
t.Fatalf("unknown subId gate: err=%v res=%+v", err, res)
|
|
}
|
|
if rows := countRows("no-such-sub"); rows != 0 {
|
|
t.Fatalf("client_hwids rows for an unknown subId = %d, want 0", rows)
|
|
}
|
|
|
|
if err := db.Model(&model.ClientRecord{}).Where("id = ?", rec.Id).UpdateColumn("enable", false).Error; err != nil {
|
|
t.Fatalf("disable client: %v", err)
|
|
}
|
|
res, err = svc.EnforceHwidForSubID(rec.SubID, HwidRequest{Hwid: "device-ghost"})
|
|
if err != nil || res != (HwidGateResult{Allowed: true}) {
|
|
t.Fatalf("disabled subId gate: err=%v res=%+v", err, res)
|
|
}
|
|
if rows := countRows(rec.SubID); rows != 0 {
|
|
t.Fatalf("client_hwids rows for a disabled subId = %d, want 0", rows)
|
|
}
|
|
|
|
if err := db.Model(&model.ClientRecord{}).Where("id = ?", rec.Id).UpdateColumn("enable", true).Error; err != nil {
|
|
t.Fatalf("enable client: %v", err)
|
|
}
|
|
res, err = svc.EnforceHwidForSubID(rec.SubID, HwidRequest{Hwid: "device-ghost"})
|
|
if err != nil || res != (HwidGateResult{Allowed: true}) {
|
|
t.Fatalf("known subId gate: err=%v res=%+v", err, res)
|
|
}
|
|
if rows := countRows(rec.SubID); rows != 1 {
|
|
t.Fatalf("client_hwids rows for a known unlimited subId = %d, want 1", rows)
|
|
}
|
|
}
|
|
|
|
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 len(row.Fingerprint) != hwidFingerprintLength {
|
|
t.Fatalf("fingerprint length = %d, want %d: %q", len(row.Fingerprint), hwidFingerprintLength, row.Fingerprint)
|
|
}
|
|
if row.DeviceModel == "updated-model" && row.UserAgent == "Karing/2.0" && row.DeviceOS == "ios" && row.OsVersion == "18" {
|
|
foundUpdated = true
|
|
want := hashHwid(firstRaw)[:hwidFingerprintLength]
|
|
if row.Fingerprint != want {
|
|
t.Fatalf("fingerprint = %q, want %q", row.Fingerprint, want)
|
|
}
|
|
}
|
|
}
|
|
if !foundUpdated {
|
|
t.Fatalf("updated HWID metadata missing: %#v", list)
|
|
}
|
|
|
|
if err := svc.setClientLimitHwidByEmail(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 TestDeleteClientHwid(t *testing.T) {
|
|
initClientHwidTestDB(t)
|
|
svc := &ClientService{}
|
|
db := database.GetDB()
|
|
|
|
rec := seedHwidClient(t, 5)
|
|
if _, err := svc.EnforceHwidForSubID(rec.SubID, HwidRequest{Hwid: "device-own"}); err != nil {
|
|
t.Fatalf("register own device: %v", err)
|
|
}
|
|
list, err := svc.ListClientHwids(rec.Email)
|
|
if err != nil || len(list) != 1 {
|
|
t.Fatalf("list own devices: err=%v list=%+v", err, list)
|
|
}
|
|
ownID := list[0].Id
|
|
|
|
other := &model.ClientRecord{Email: "other@example.com", SubID: "sub-other", UUID: "33333333-2222-4333-8444-555555555555", Enable: true, LimitHwid: 5}
|
|
if err := db.Create(other).Error; err != nil {
|
|
t.Fatalf("seed other client: %v", err)
|
|
}
|
|
if _, err := svc.EnforceHwidForSubID(other.SubID, HwidRequest{Hwid: "device-foreign"}); err != nil {
|
|
t.Fatalf("register foreign device: %v", err)
|
|
}
|
|
otherList, err := svc.ListClientHwids(other.Email)
|
|
if err != nil || len(otherList) != 1 {
|
|
t.Fatalf("list foreign devices: err=%v list=%+v", err, otherList)
|
|
}
|
|
foreignID := otherList[0].Id
|
|
|
|
if err := svc.DeleteClientHwid(rec.Email, foreignID); err == nil {
|
|
t.Fatalf("deleting a foreign sub_id's device id should fail")
|
|
}
|
|
if list, err := svc.ListClientHwids(other.Email); err != nil || len(list) != 1 {
|
|
t.Fatalf("foreign device should survive a cross-sub_id delete attempt: err=%v list=%+v", err, list)
|
|
}
|
|
|
|
if err := svc.DeleteClientHwid(rec.Email, 999999); err == nil {
|
|
t.Fatalf("deleting an unknown id should fail")
|
|
}
|
|
|
|
if err := svc.DeleteClientHwid(rec.Email, ownID); err != nil {
|
|
t.Fatalf("delete own device: %v", err)
|
|
}
|
|
if list, err := svc.ListClientHwids(rec.Email); err != nil || len(list) != 0 {
|
|
t.Fatalf("own device should be gone: err=%v list=%+v", err, list)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
func TestClientHwidGateHoldsWhileClientDisabled(t *testing.T) {
|
|
initClientHwidTestDB(t)
|
|
svc := &ClientService{}
|
|
db := database.GetDB()
|
|
rec := seedHwidClient(t, 1)
|
|
|
|
if res, err := svc.EnforceHwidForSubID(rec.SubID, HwidRequest{Hwid: "device-one"}); err != nil || !res.Allowed {
|
|
t.Fatalf("register first device = %+v, %v", res, err)
|
|
}
|
|
if err := db.Model(&model.ClientRecord{}).Where("id = ?", rec.Id).UpdateColumn("enable", false).Error; err != nil {
|
|
t.Fatalf("disable client: %v", err)
|
|
}
|
|
|
|
assertStillLimited := func(stage string) {
|
|
t.Helper()
|
|
res, err := svc.EnforceHwidForSubID(rec.SubID, HwidRequest{Hwid: "device-two"})
|
|
if err != nil {
|
|
t.Fatalf("%s: second device: %v", stage, err)
|
|
}
|
|
if res.Allowed || !res.MaxDevicesReached || res.Limit != 1 {
|
|
t.Fatalf("%s: disabled client must keep its device limit: %+v", stage, res)
|
|
}
|
|
if res, err = svc.EnforceHwidForSubID(rec.SubID, HwidRequest{Hwid: "device-one"}); err != nil || !res.Allowed {
|
|
t.Fatalf("%s: registered device = %+v, %v", stage, res, err)
|
|
}
|
|
var count int64
|
|
if err := db.Model(&model.ClientHwid{}).Where("sub_id = ?", rec.SubID).Count(&count).Error; err != nil {
|
|
t.Fatalf("%s: count HWIDs: %v", stage, err)
|
|
}
|
|
if count != 1 {
|
|
t.Fatalf("%s: stored HWIDs = %d, want 1", stage, count)
|
|
}
|
|
}
|
|
assertStillLimited("only client disabled")
|
|
|
|
// An enabled sibling without a limit must not lift it either.
|
|
if err := db.Create(&model.ClientRecord{Email: "second@example.com", SubID: rec.SubID, UUID: "22222222-2222-4333-8444-555555555555", Enable: true, LimitHwid: 0}).Error; err != nil {
|
|
t.Fatalf("seed second client: %v", err)
|
|
}
|
|
assertStillLimited("unlimited sibling enabled")
|
|
}
|
|
|
|
func TestClientHwidSlotStatus(t *testing.T) {
|
|
initClientHwidTestDB(t)
|
|
svc := &ClientService{}
|
|
db := database.GetDB()
|
|
rec := seedHwidClient(t, 1)
|
|
|
|
status, found, err := svc.HwidSlotStatusForSubID("no-such-sub")
|
|
if err != nil || found || status != (HwidSlotStatus{}) {
|
|
t.Fatalf("unknown subId = (%+v, %v, %v), want zero status and found=false", status, found, err)
|
|
}
|
|
|
|
status, found, err = svc.HwidSlotStatusForSubID(" " + rec.SubID + " ")
|
|
if err != nil || !found {
|
|
t.Fatalf("padded subId = (%+v, %v, %v), want found=true", status, found, err)
|
|
}
|
|
if want := (HwidSlotStatus{Active: true, Limit: 1, Remaining: 1}); status != want {
|
|
t.Fatalf("empty slots = %+v, want %+v", status, want)
|
|
}
|
|
|
|
// A shared sub_id takes the highest limit, matching the enforcement gate.
|
|
if err := db.Create(&model.ClientRecord{Email: "second@example.com", SubID: rec.SubID, UUID: "22222222-2222-4333-8444-555555555555", Enable: true, LimitHwid: 3}).Error; err != nil {
|
|
t.Fatalf("seed second client: %v", err)
|
|
}
|
|
for _, hwid := range []string{"device-one", "device-two", "device-three"} {
|
|
if _, err := svc.EnforceHwidForSubID(rec.SubID, HwidRequest{Hwid: hwid}); err != nil {
|
|
t.Fatalf("register %s: %v", hwid, err)
|
|
}
|
|
}
|
|
status, found, err = svc.HwidSlotStatusForSubID(rec.SubID)
|
|
if err != nil || !found {
|
|
t.Fatalf("shared subId = (%+v, %v, %v), want found=true", status, found, err)
|
|
}
|
|
if want := (HwidSlotStatus{Active: true, Limit: 3, Registered: 3, Full: true}); status != want {
|
|
t.Fatalf("full slots = %+v, want %+v", status, want)
|
|
}
|
|
|
|
// Deleting the highest-limit client drops the effective limit below the
|
|
// registered count, and remaining must clamp at zero instead of going negative.
|
|
if err := db.Where("email = ?", "second@example.com").Delete(&model.ClientRecord{}).Error; err != nil {
|
|
t.Fatalf("delete second client: %v", err)
|
|
}
|
|
status, _, err = svc.HwidSlotStatusForSubID(rec.SubID)
|
|
if err != nil {
|
|
t.Fatalf("lowered limit: %v", err)
|
|
}
|
|
if want := (HwidSlotStatus{Active: true, Limit: 1, Registered: 3, Remaining: 0, Full: true}); status != want {
|
|
t.Fatalf("over-limit slots = %+v, want %+v", status, want)
|
|
}
|
|
|
|
if err := db.Model(&model.ClientRecord{}).Where("sub_id = ?", rec.SubID).UpdateColumn("enable", false).Error; err != nil {
|
|
t.Fatalf("disable clients: %v", err)
|
|
}
|
|
status, found, err = svc.HwidSlotStatusForSubID(rec.SubID)
|
|
if err != nil || found || status != (HwidSlotStatus{}) {
|
|
t.Fatalf("disabled subId = (%+v, %v, %v), want zero status and found=false", status, found, err)
|
|
}
|
|
}
|