mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 11:15:03 +03:00
9a95153f0e
The subscription routes run the HWID gate before they look up the subId, and the gate read "no device limit" and "no such subscription" alike, so a request naming any path segment with an X-HWID header was answered 404 yet stored that segment, up to about 1 MiB, in client_hwids for good. Anyone reaching the subscription port could fill the disk until the panel could no longer save anything. An unlimited subscription is tracked only while an enabled client owns its subId, and a one-time seeder drops the rows already stored for subIds no client owns.
394 lines
11 KiB
Go
394 lines
11 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"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
|
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
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
|
|
)
|
|
|
|
var errClientHwidWriteNotSerialized = errors.New("client HWID write requires the serialized transaction")
|
|
|
|
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
|
|
}
|
|
|
|
// subIDHasEnabledClient separates "no device limit" from "no such subscription":
|
|
// effectiveHwidLimitForSubID yields 0 for both.
|
|
func subIDHasEnabledClient(tx *gorm.DB, subID string) (bool, error) {
|
|
var enabled int64
|
|
err := tx.Model(&model.ClientRecord{}).
|
|
Where("sub_id = ? AND enable = ?", subID, true).
|
|
Count(&enabled).Error
|
|
return enabled > 0, 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
|
|
}
|
|
req = normalizeHwidRequest(req)
|
|
if limit <= 0 {
|
|
res.Allowed = true
|
|
if len(req.Hwid) < minHwidLength {
|
|
return res, nil
|
|
}
|
|
// The gate runs before the sub lookup; a subId no client owns gets no row.
|
|
known, err := subIDHasEnabledClient(db, subID)
|
|
if err != nil {
|
|
return res, err
|
|
}
|
|
if known {
|
|
trackUnlimitedHwid(db, subID, req)
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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) {
|
|
subID = strings.TrimSpace(subID)
|
|
if subID == "" {
|
|
return status, false, nil
|
|
}
|
|
|
|
db := database.GetDB()
|
|
known, err := subIDHasEnabledClient(db, subID)
|
|
if err != nil || !known {
|
|
return status, false, err
|
|
}
|
|
|
|
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(®istered).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
|
|
}
|
|
|
|
// Serialize the limit write and trim with SyncInbound and client deletion.
|
|
func (s *ClientService) setClientLimitHwidByEmail(email string, limit int) error {
|
|
return runSerializedTx(func(tx *gorm.DB) error {
|
|
return s.setClientLimitHwidByEmailTx(tx, email, limit)
|
|
})
|
|
}
|
|
|
|
func (s *ClientService) setClientLimitHwidByEmailTx(tx *gorm.DB, email string, limit int) error {
|
|
if !isSerializedTx(tx) {
|
|
return errClientHwidWriteNotSerialized
|
|
}
|
|
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 !isSerializedTx(tx) {
|
|
return errClientHwidWriteNotSerialized
|
|
}
|
|
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
|
|
}
|