Files
3x-ui/internal/web/service/client_hwid.go
mrchatam 8082ab4d74 feat(clients): show short HWID fingerprint in admin device list (#6464)
* 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>
2026-09-12 11:13:22 +02:00

354 lines
9.0 KiB
Go

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
}
// HwidSlotStatus is the aggregate device-slot view exposed to subscribers:
// counters only, no hwid value or hash, no email, no device metadata.
type HwidSlotStatus struct {
Active bool `json:"active" example:"true"`
Limit int `json:"limit" example:"2"`
Registered int `json:"registered" example:"1"`
Remaining int `json:"remaining" example:"1"`
Full bool `json:"full" example:"false"`
}
const (
minHwidLength = 6
hwidFingerprintLength = 12
)
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"`
Fingerprint string `json:"fingerprint"`
}
func hashHwid(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}
func shortHwidFingerprint(hash string) string {
if len(hash) <= hwidFingerprintLength {
return hash
}
return hash[:hwidFingerprintLength]
}
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
}
// 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) {
subID = strings.TrimSpace(subID)
if subID == "" {
return status, false, nil
}
db := database.GetDB()
var enabled int64
if err := db.Model(&model.ClientRecord{}).
Where("sub_id = ? AND enable = ?", subID, true).
Count(&enabled).Error; err != nil {
return status, false, err
}
if enabled == 0 {
return status, false, nil
}
limit, err := effectiveHwidLimitForSubID(db, subID)
if err != nil {
return status, false, err
}
if limit <= 0 {
return status, true, nil
}
var registered int64
if err := db.Model(&model.ClientHwid{}).Where("sub_id = ?", subID).Count(&registered).Error; err != nil {
return status, false, err
}
status.Active = true
status.Limit = limit
status.Registered = int(registered)
status.Remaining = max(limit-status.Registered, 0)
status.Full = status.Registered >= limit
return status, true, nil
}
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,
Fingerprint: shortHwidFingerprint(r.HwidHash),
})
}
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
}
// DeleteClientHwid removes one device, scoped to the client's sub_id: ids
// are a global auto-increment, so an id outside this subscription won't match.
func (s *ClientService) DeleteClientHwid(email string, id int) error {
rec, err := s.GetRecordByEmail(nil, email)
if err != nil {
return err
}
subID := strings.TrimSpace(rec.SubID)
if subID == "" {
return errors.New("client has no subscription id")
}
res := database.GetDB().Where("sub_id = ? AND id = ?", subID, id).Delete(&model.ClientHwid{})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return errors.New("device not found")
}
return nil
}
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
}