mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-01-13 09:12:44 +00:00
342 lines
12 KiB
Go
342 lines
12 KiB
Go
// Package service provides HWID (Hardware ID) management for clients.
|
|
// HWID is provided explicitly by client applications via HTTP headers (x-hwid).
|
|
// Server MUST NOT generate or derive HWID from IP, User-Agent, or access logs.
|
|
package service
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/mhsanaei/3x-ui/v2/database"
|
|
"github.com/mhsanaei/3x-ui/v2/database/model"
|
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// ClientHWIDService provides business logic for managing client HWIDs.
|
|
type ClientHWIDService struct{}
|
|
|
|
// GetHWIDsForClient retrieves all HWIDs associated with a client.
|
|
func (s *ClientHWIDService) GetHWIDsForClient(clientId int) ([]*model.ClientHWID, error) {
|
|
db := database.GetDB()
|
|
var hwids []*model.ClientHWID
|
|
err := db.Where("client_id = ?", clientId).Order("last_seen_at DESC").Find(&hwids).Error
|
|
return hwids, err
|
|
}
|
|
|
|
// AddHWIDForClient adds a new HWID for a client with device metadata.
|
|
// HWID must be provided explicitly (not generated).
|
|
// If the client has HWID restrictions enabled, checks if the limit is exceeded.
|
|
func (s *ClientHWIDService) AddHWIDForClient(clientId int, hwid string, deviceOS string, deviceModel string, osVersion string, ipAddress string, userAgent string) (*model.ClientHWID, error) {
|
|
// Normalize HWID (trim, but preserve case - HWID is opaque identifier from client)
|
|
hwid = strings.TrimSpace(hwid)
|
|
if hwid == "" {
|
|
return nil, fmt.Errorf("HWID cannot be empty")
|
|
}
|
|
|
|
// Get client to check restrictions
|
|
clientService := ClientService{}
|
|
client, err := clientService.GetClient(clientId)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get client: %w", err)
|
|
}
|
|
if client == nil {
|
|
return nil, fmt.Errorf("client not found")
|
|
}
|
|
|
|
db := database.GetDB()
|
|
tx := db.Begin()
|
|
defer func() {
|
|
if err != nil {
|
|
tx.Rollback()
|
|
} else {
|
|
tx.Commit()
|
|
}
|
|
}()
|
|
|
|
// Check if HWID already exists for this client
|
|
var existingHWID model.ClientHWID
|
|
err = tx.Where("client_id = ? AND hwid = ?", clientId, hwid).First(&existingHWID).Error
|
|
if err == nil {
|
|
// HWID exists - update last seen and IP
|
|
now := time.Now().Unix()
|
|
updates := map[string]interface{}{
|
|
"last_seen_at": now,
|
|
"ip_address": ipAddress,
|
|
}
|
|
if userAgent != "" {
|
|
updates["user_agent"] = userAgent
|
|
}
|
|
// Update device metadata if provided
|
|
if deviceOS != "" {
|
|
updates["device_os"] = deviceOS
|
|
}
|
|
if deviceModel != "" {
|
|
updates["device_model"] = deviceModel
|
|
}
|
|
if osVersion != "" {
|
|
updates["os_version"] = osVersion
|
|
}
|
|
existingHWID.IsActive = true
|
|
err = tx.Model(&existingHWID).Updates(updates).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Reload to get updated fields
|
|
tx.First(&existingHWID, existingHWID.Id)
|
|
return &existingHWID, nil
|
|
} else if err != gorm.ErrRecordNotFound {
|
|
return nil, fmt.Errorf("failed to check existing HWID: %w", err)
|
|
}
|
|
|
|
// HWID doesn't exist - check if we can add it
|
|
var activeHWIDCount int64
|
|
if client.HWIDEnabled {
|
|
// Count active HWIDs for this client
|
|
err = tx.Model(&model.ClientHWID{}).Where("client_id = ? AND is_active = ?", clientId, true).Count(&activeHWIDCount).Error
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to count active HWIDs: %w", err)
|
|
}
|
|
|
|
// Check limit (0 means unlimited)
|
|
if client.MaxHWID > 0 && int(activeHWIDCount) >= client.MaxHWID {
|
|
return nil, fmt.Errorf("HWID limit exceeded: max %d devices allowed, current: %d", client.MaxHWID, activeHWIDCount)
|
|
}
|
|
} else {
|
|
// Count all HWIDs for device naming even if restriction is disabled
|
|
err = tx.Model(&model.ClientHWID{}).Where("client_id = ?", clientId).Count(&activeHWIDCount).Error
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to count HWIDs: %w", err)
|
|
}
|
|
}
|
|
|
|
// Create new HWID record
|
|
now := time.Now().Unix()
|
|
newHWID := &model.ClientHWID{
|
|
ClientId: clientId,
|
|
HWID: hwid,
|
|
DeviceOS: deviceOS,
|
|
DeviceModel: deviceModel,
|
|
OSVersion: osVersion,
|
|
IPAddress: ipAddress,
|
|
FirstSeenIP: ipAddress,
|
|
UserAgent: userAgent,
|
|
IsActive: true,
|
|
FirstSeenAt: now,
|
|
LastSeenAt: now,
|
|
DeviceName: fmt.Sprintf("Device %d", activeHWIDCount+1), // Legacy field, deprecated
|
|
}
|
|
|
|
err = tx.Create(newHWID).Error
|
|
if err != nil {
|
|
logger.Errorf("Failed to create HWID record in database: %v", err)
|
|
return nil, fmt.Errorf("failed to create HWID: %w", err)
|
|
}
|
|
|
|
logger.Debugf("Successfully created HWID record: clientId=%d, hwid=%s, hwidId=%d", clientId, hwid, newHWID.Id)
|
|
return newHWID, nil
|
|
}
|
|
|
|
// RemoveHWID removes a HWID from a client.
|
|
func (s *ClientHWIDService) RemoveHWID(hwidId int) error {
|
|
db := database.GetDB()
|
|
return db.Delete(&model.ClientHWID{}, hwidId).Error
|
|
}
|
|
|
|
// DeactivateHWID deactivates a HWID (marks as inactive instead of deleting).
|
|
func (s *ClientHWIDService) DeactivateHWID(hwidId int) error {
|
|
db := database.GetDB()
|
|
return db.Model(&model.ClientHWID{}).Where("id = ?", hwidId).Update("is_active", false).Error
|
|
}
|
|
|
|
// CheckHWIDAllowed checks if a HWID is allowed for a client.
|
|
// Returns true if HWID restriction is disabled, or if HWID is in the allowed list.
|
|
// NOTE: This method does NOT auto-register HWID. Use RegisterHWIDFromHeaders for registration.
|
|
// Behavior depends on hwidMode setting:
|
|
// - "off": Always returns true (HWID tracking disabled)
|
|
// - "client_header": Requires explicit HWID registration, checks against registered devices
|
|
// - "legacy_fingerprint": Legacy mode (deprecated)
|
|
func (s *ClientHWIDService) CheckHWIDAllowed(clientId int, hwid string) (bool, error) {
|
|
// Check HWID mode setting
|
|
settingService := SettingService{}
|
|
hwidMode, err := settingService.GetHwidMode()
|
|
if err != nil {
|
|
logger.Warningf("Failed to get hwidMode setting, defaulting to client_header: %v", err)
|
|
hwidMode = "client_header"
|
|
}
|
|
|
|
// If HWID tracking is disabled globally, allow all
|
|
if hwidMode == "off" {
|
|
return true, nil
|
|
}
|
|
|
|
// Normalize HWID (trim, but preserve case - HWID is opaque identifier from client)
|
|
hwid = strings.TrimSpace(hwid)
|
|
if hwid == "" {
|
|
// In client_header mode, empty HWID means "unknown device" - don't count, but allow
|
|
if hwidMode == "client_header" {
|
|
return true, nil // Allow but don't count as registered device
|
|
}
|
|
return false, fmt.Errorf("HWID cannot be empty")
|
|
}
|
|
|
|
// Get client
|
|
clientService := ClientService{}
|
|
client, err := clientService.GetClient(clientId)
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to get client: %w", err)
|
|
}
|
|
if client == nil {
|
|
return false, fmt.Errorf("client not found")
|
|
}
|
|
|
|
// If HWID restriction is disabled for this client, allow all
|
|
if !client.HWIDEnabled {
|
|
return true, nil
|
|
}
|
|
|
|
// In client_header mode, HWID must be explicitly registered
|
|
if hwidMode == "client_header" {
|
|
// Check if HWID exists and is active
|
|
db := database.GetDB()
|
|
var hwidRecord model.ClientHWID
|
|
err = db.Where("client_id = ? AND hwid = ? AND is_active = ?", clientId, hwid, true).First(&hwidRecord).Error
|
|
if err == nil {
|
|
// HWID exists and is active - update last seen
|
|
db.Model(&hwidRecord).Update("last_seen_at", time.Now().Unix())
|
|
return true, nil
|
|
} else if err == gorm.ErrRecordNotFound {
|
|
// HWID not found - check if we're under limit (allows registration)
|
|
var activeHWIDCount int64
|
|
err = db.Model(&model.ClientHWID{}).Where("client_id = ? AND is_active = ?", clientId, true).Count(&activeHWIDCount).Error
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to count active HWIDs: %w", err)
|
|
}
|
|
|
|
// If under limit, allow (registration can happen via RegisterHWIDFromHeaders)
|
|
if client.MaxHWID == 0 || int(activeHWIDCount) < client.MaxHWID {
|
|
return true, nil
|
|
}
|
|
|
|
// Limit reached, HWID not registered
|
|
return false, fmt.Errorf("HWID limit exceeded: max %d devices allowed, current: %d", client.MaxHWID, activeHWIDCount)
|
|
}
|
|
|
|
return false, fmt.Errorf("failed to check HWID: %w", err)
|
|
}
|
|
|
|
// Legacy fingerprint mode (deprecated) - kept for backward compatibility
|
|
// This mode may use fingerprint-based HWID generation (not recommended)
|
|
if hwidMode == "legacy_fingerprint" {
|
|
// Check if HWID exists and is active
|
|
db := database.GetDB()
|
|
var hwidRecord model.ClientHWID
|
|
err = db.Where("client_id = ? AND hwid = ? AND is_active = ?", clientId, hwid, true).First(&hwidRecord).Error
|
|
if err == nil {
|
|
// HWID exists and is active - update last seen
|
|
db.Model(&hwidRecord).Update("last_seen_at", time.Now().Unix())
|
|
return true, nil
|
|
} else if err == gorm.ErrRecordNotFound {
|
|
// HWID not found - check limit
|
|
var activeHWIDCount int64
|
|
err = db.Model(&model.ClientHWID{}).Where("client_id = ? AND is_active = ?", clientId, true).Count(&activeHWIDCount).Error
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to count active HWIDs: %w", err)
|
|
}
|
|
|
|
// If under limit, allow (legacy mode may auto-register via job)
|
|
if client.MaxHWID == 0 || int(activeHWIDCount) < client.MaxHWID {
|
|
return true, nil
|
|
}
|
|
|
|
// Limit reached, HWID not in list
|
|
return false, nil
|
|
}
|
|
|
|
return false, fmt.Errorf("failed to check HWID: %w", err)
|
|
}
|
|
|
|
// Unknown mode - default to allowing (fail open)
|
|
logger.Warningf("Unknown hwidMode: %s, allowing request", hwidMode)
|
|
return true, nil
|
|
}
|
|
|
|
// RegisterHWIDFromHeaders registers a HWID from HTTP headers provided by client application.
|
|
// This is the primary method for HWID registration in client_header mode.
|
|
// Headers:
|
|
// - x-hwid (required): Hardware ID provided by client
|
|
// - x-device-os (optional): Device operating system
|
|
// - x-device-model (optional): Device model
|
|
// - x-ver-os (optional): OS version
|
|
// - user-agent (optional): User agent string
|
|
func (s *ClientHWIDService) RegisterHWIDFromHeaders(clientId int, hwid string, deviceOS string, deviceModel string, osVersion string, ipAddress string, userAgent string) (*model.ClientHWID, error) {
|
|
// HWID must be provided explicitly
|
|
hwid = strings.TrimSpace(hwid)
|
|
if hwid == "" {
|
|
return nil, fmt.Errorf("HWID is required (x-hwid header missing)")
|
|
}
|
|
|
|
// Get client to check restrictions
|
|
clientService := ClientService{}
|
|
client, err := clientService.GetClient(clientId)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get client: %w", err)
|
|
}
|
|
if client == nil {
|
|
return nil, fmt.Errorf("client not found")
|
|
}
|
|
|
|
// Check HWID mode setting
|
|
settingService := SettingService{}
|
|
hwidMode, err := settingService.GetHwidMode()
|
|
if err != nil {
|
|
logger.Warningf("Failed to get hwidMode setting, defaulting to client_header: %v", err)
|
|
hwidMode = "client_header"
|
|
}
|
|
|
|
// In client_header mode, HWID must be provided explicitly (which it is, since we're here)
|
|
// In legacy_fingerprint mode, this method should not be called (use legacy methods)
|
|
if hwidMode == "off" {
|
|
// HWID tracking disabled - allow but don't register
|
|
return nil, nil
|
|
}
|
|
|
|
// Register or update HWID
|
|
logger.Debugf("RegisterHWIDFromHeaders: calling AddHWIDForClient for clientId=%d, hwid=%s", clientId, hwid)
|
|
return s.AddHWIDForClient(clientId, hwid, deviceOS, deviceModel, osVersion, ipAddress, userAgent)
|
|
}
|
|
|
|
// UpdateHWIDLastSeen updates the last seen timestamp and IP address for a HWID.
|
|
func (s *ClientHWIDService) UpdateHWIDLastSeen(clientId int, hwid string, ipAddress string) error {
|
|
hwid = strings.TrimSpace(hwid) // Preserve case - HWID is opaque identifier
|
|
if hwid == "" {
|
|
return fmt.Errorf("HWID cannot be empty")
|
|
}
|
|
|
|
db := database.GetDB()
|
|
return db.Model(&model.ClientHWID{}).
|
|
Where("client_id = ? AND hwid = ?", clientId, hwid).
|
|
Updates(map[string]interface{}{
|
|
"last_seen_at": time.Now().Unix(),
|
|
"ip_address": ipAddress,
|
|
}).Error
|
|
}
|
|
|
|
// GenerateFingerprintHWID generates a fingerprint-based HWID from connection parameters.
|
|
// DEPRECATED: This method is only for legacy_fingerprint mode (backward compatibility).
|
|
// In client_header mode, HWID must be provided explicitly by client via x-hwid header.
|
|
// Do NOT use this method for new implementations.
|
|
func (s *ClientHWIDService) GenerateFingerprintHWID(email string, ipAddress string, userAgent string) string {
|
|
// DEPRECATED: This method should only be used in legacy_fingerprint mode
|
|
// Combine parameters to create a fingerprint
|
|
fingerprint := fmt.Sprintf("%s|%s|%s", email, ipAddress, userAgent)
|
|
|
|
// Hash the fingerprint to create a stable HWID
|
|
// NOTE: This approach is deprecated and may cause false positives
|
|
// when IP addresses change or clients reconnect from different networks
|
|
hash := sha256.Sum256([]byte(fingerprint))
|
|
return hex.EncodeToString(hash[:])[:32] // Use first 32 chars of hash
|
|
}
|