mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-15 19:02:18 +03:00
* feat(clients): show short HWID fingerprint in admin device list Expose a 12-char prefix of the stored hwid_hash in the admin HWID list API and UI so admins can distinguish devices without querying the database. Full hashes and raw HWIDs remain unexposed. Fixes #6359 * ci: retrigger release matrix after 386 dependency download flake --------- Co-authored-by: mrchatam <mrchatam@users.noreply.github.com> Co-authored-by: mrchatam <287639636+mrchatam@users.noreply.github.com>
285 lines
9.5 KiB
Go
285 lines
9.5 KiB
Go
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 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(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 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 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)
|
|
}
|
|
}
|