diff --git a/database/db.go b/database/db.go index b33a0621..f1bc99df 100644 --- a/database/db.go +++ b/database/db.go @@ -40,6 +40,11 @@ func initModels() error { &model.HistoryOfSeeders{}, &model.Node{}, &model.InboundNodeMapping{}, + &model.ClientEntity{}, + &model.ClientInboundMapping{}, + &model.Host{}, + &model.HostInboundMapping{}, + &model.ClientHWID{}, // HWID tracking for clients } for _, model := range models { if err := db.AutoMigrate(model); err != nil { diff --git a/database/model/model.go b/database/model/model.go index 51203a43..5ad12305 100644 --- a/database/model/model.go +++ b/database/model/model.go @@ -104,6 +104,8 @@ type Setting struct { } // Client represents a client configuration for Xray inbounds with traffic limits and settings. +// This is a legacy struct used for JSON parsing from inbound Settings. +// For database operations, use ClientEntity instead. type Client struct { ID string `json:"id"` // Unique client identifier Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm") @@ -122,6 +124,42 @@ type Client struct { UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp } +// ClientEntity represents a client as a separate database entity. +// Clients can be assigned to multiple inbounds. +type ClientEntity struct { + Id int `json:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier + UserId int `json:"userId" gorm:"index"` // Associated user ID + Email string `json:"email" form:"email" gorm:"uniqueIndex:idx_user_email"` // Client email identifier (unique per user) + UUID string `json:"uuid" form:"uuid"` // UUID/ID for VMESS/VLESS + Security string `json:"security" form:"security"` // Security method (e.g., "auto", "aes-128-gcm") + Password string `json:"password" form:"password"` // Client password (for Trojan/Shadowsocks) + Flow string `json:"flow" form:"flow"` // Flow control (XTLS) + LimitIP int `json:"limitIp" form:"limitIp"` // IP limit for this client + TotalGB int64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB + ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp + Enable bool `json:"enable" form:"enable"` // Whether the client is enabled + TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications + SubID string `json:"subId" form:"subId" gorm:"index"` // Subscription identifier + Comment string `json:"comment" form:"comment"` // Client comment + Reset int `json:"reset" form:"reset"` // Reset period in days + CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"` // Creation timestamp + UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime"` // Last update timestamp + + // Relations (not stored in DB, loaded via joins) + InboundIds []int `json:"inboundIds,omitempty" form:"-" gorm:"-"` // Inbound IDs this client is assigned to + + // Traffic statistics (loaded from client_traffics table, not stored in ClientEntity table) + Up int64 `json:"up,omitempty" form:"-" gorm:"-"` // Upload traffic in bytes + Down int64 `json:"down,omitempty" form:"-" gorm:"-"` // Download traffic in bytes + AllTime int64 `json:"allTime,omitempty" form:"-" gorm:"-"` // All-time traffic usage + LastOnline int64 `json:"lastOnline,omitempty" form:"-" gorm:"-"` // Last online timestamp + + // HWID (Hardware ID) restrictions + HWIDEnabled bool `json:"hwidEnabled" form:"hwidEnabled" gorm:"column:hwid_enabled;default:false"` // Whether HWID restriction is enabled for this client + MaxHWID int `json:"maxHwid" form:"maxHwid" gorm:"column:max_hwid;default:1"` // Maximum number of allowed HWID devices (0 = unlimited) + HWIDs []*ClientHWID `json:"hwids,omitempty" form:"-" gorm:"-"` // Registered HWIDs for this client (loaded from client_hwids table, not stored in ClientEntity table) +} + // Node represents a worker node in multi-node architecture. type Node struct { Id int `json:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier @@ -139,4 +177,69 @@ type InboundNodeMapping struct { Id int `json:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier InboundId int `json:"inboundId" form:"inboundId" gorm:"uniqueIndex:idx_inbound_node"` // Inbound ID NodeId int `json:"nodeId" form:"nodeId" gorm:"uniqueIndex:idx_inbound_node"` // Node ID +} + +// ClientInboundMapping maps clients to inbounds (many-to-many relationship). +type ClientInboundMapping struct { + Id int `json:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier + ClientId int `json:"clientId" form:"clientId" gorm:"uniqueIndex:idx_client_inbound"` // Client ID + InboundId int `json:"inboundId" form:"inboundId" gorm:"uniqueIndex:idx_client_inbound"` // Inbound ID +} + +// Host represents a proxy/balancer host configuration for multi-node mode. +// Hosts can override the node address when generating subscription links. +type Host struct { + Id int `json:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier + UserId int `json:"userId" gorm:"index"` // Associated user ID + Name string `json:"name" form:"name"` // Host name/identifier + Address string `json:"address" form:"address"` // Host address (IP or domain) + Port int `json:"port" form:"port"` // Host port (0 means use inbound port) + Protocol string `json:"protocol" form:"protocol"` // Protocol override (optional) + Remark string `json:"remark" form:"remark"` // Host remark/description + Enable bool `json:"enable" form:"enable"` // Whether the host is enabled + CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"` // Creation timestamp + UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime"` // Last update timestamp + + // Relations (not stored in DB, loaded via joins) + InboundIds []int `json:"inboundIds,omitempty" form:"-" gorm:"-"` // Inbound IDs this host applies to +} + +// HostInboundMapping maps hosts to inbounds (many-to-many relationship). +type HostInboundMapping struct { + Id int `json:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier + HostId int `json:"hostId" form:"hostId" gorm:"uniqueIndex:idx_host_inbound"` // Host ID + InboundId int `json:"inboundId" form:"inboundId" gorm:"uniqueIndex:idx_host_inbound"` // Inbound ID +} + +// ClientHWID represents a hardware ID (HWID) associated with a client. +// 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. +type ClientHWID struct { + // TableName specifies the table name for GORM + // GORM by default would use "client_hwids" but the actual table is "client_hw_ids" + Id int `json:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier + ClientId int `json:"clientId" form:"clientId" gorm:"column:client_id;index:idx_client_hwid"` // Client ID + HWID string `json:"hwid" form:"hwid" gorm:"column:hwid;index:idx_client_hwid"` // Hardware ID (unique per client, provided by client via x-hwid header) + DeviceName string `json:"deviceName" form:"deviceName" gorm:"column:device_name"` // Optional device name/description (deprecated, use DeviceModel instead) + DeviceOS string `json:"deviceOs" form:"deviceOs" gorm:"column:device_os"` // Device operating system (from x-device-os header) + DeviceModel string `json:"deviceModel" form:"deviceModel" gorm:"column:device_model"` // Device model (from x-device-model header) + OSVersion string `json:"osVersion" form:"osVersion" gorm:"column:os_version"` // OS version (from x-ver-os header) + FirstSeenAt int64 `json:"firstSeenAt" gorm:"column:first_seen_at;autoCreateTime"` // First time this HWID was seen (timestamp) + LastSeenAt int64 `json:"lastSeenAt" gorm:"column:last_seen_at;autoUpdateTime"` // Last time this HWID was used (timestamp) + FirstSeenIP string `json:"firstSeenIp" form:"firstSeenIp" gorm:"column:first_seen_ip"` // IP address when first seen + IsActive bool `json:"isActive" form:"isActive" gorm:"column:is_active;default:true"` // Whether this HWID is currently active + IPAddress string `json:"ipAddress" form:"ipAddress" gorm:"column:ip_address"` // Last known IP address for this HWID + UserAgent string `json:"userAgent" form:"userAgent" gorm:"column:user_agent"` // User agent or client identifier (if available) + BlockedAt *int64 `json:"blockedAt,omitempty" form:"blockedAt" gorm:"column:blocked_at"` // Timestamp when HWID was blocked (null if not blocked) + BlockReason string `json:"blockReason,omitempty" form:"blockReason" gorm:"column:block_reason"` // Reason for blocking (e.g., "HWID limit exceeded") + + // Legacy fields (deprecated, kept for backward compatibility) + FirstSeen int64 `json:"firstSeen,omitempty" gorm:"-"` // Deprecated: use FirstSeenAt + LastSeen int64 `json:"lastSeen,omitempty" gorm:"-"` // Deprecated: use LastSeenAt +} + +// TableName specifies the table name for ClientHWID. +// GORM by default would use "client_hwids" but the actual table is "client_hw_ids" +func (ClientHWID) TableName() string { + return "client_hw_ids" } \ No newline at end of file diff --git a/node/docker-compose.yml b/node/docker-compose.yml index d72d6407..4d9b2803 100644 --- a/node/docker-compose.yml +++ b/node/docker-compose.yml @@ -18,7 +18,44 @@ services: # If the file doesn't exist, it will be created when XRAY config is first applied networks: - xray-network + node2: + build: + context: .. + dockerfile: node/Dockerfile + container_name: 3x-ui-node2 + restart: unless-stopped + environment: +# - NODE_API_KEY=${NODE_API_KEY:-change-me-to-secure-key} + - NODE_API_KEY=test-key + ports: + - "8081:8080" + - "44001:44001" + volumes: + - ./bin/config.json:/app/bin/config.json + - ./logs:/app/logs + # Note: config.json is mounted directly for persistence + # If the file doesn't exist, it will be created when XRAY config is first applied + networks: + - xray-network + node3: + build: + context: .. + dockerfile: node/Dockerfile + container_name: 3x-ui-node3 + restart: unless-stopped + environment: + - NODE_API_KEY=test-key + ports: + - "8082:8080" + - "44002:44002" + volumes: + - ./bin/config.json:/app/bin/config.json + - ./logs:/app/logs + # Note: config.json is mounted directly for persistence + # If the file doesn't exist, it will be created when XRAY config is first applied + networks: + - xray-network networks: xray-network: driver: bridge diff --git a/sub/subController.go b/sub/subController.go index a219dd63..2ddf1fe4 100644 --- a/sub/subController.go +++ b/sub/subController.go @@ -41,8 +41,10 @@ func NewSUBController( subTitle string, ) *SUBController { sub := NewSubService(showInfo, rModel) - // Initialize NodeService for multi-node support + // Initialize services for multi-node support and new architecture sub.nodeService = service.NodeService{} + sub.hostService = service.HostService{} + sub.clientService = service.ClientService{} a := &SUBController{ subTitle: subTitle, subPath: subPath, @@ -73,7 +75,7 @@ func (a *SUBController) initRouter(g *gin.RouterGroup) { func (a *SUBController) subs(c *gin.Context) { subId := c.Param("subid") scheme, host, hostWithPort, hostHeader := a.subService.ResolveRequest(c) - subs, lastOnline, traffic, err := a.subService.GetSubs(subId, host) + subs, lastOnline, traffic, err := a.subService.GetSubs(subId, host, c) // Pass context for HWID registration if err != nil || len(subs) == 0 { c.String(400, "Error!") } else { @@ -130,7 +132,7 @@ func (a *SUBController) subs(c *gin.Context) { // Add headers header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000) - a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle) + a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, subId) if a.subEncrypt { c.String(200, base64.StdEncoding.EncodeToString([]byte(result))) @@ -144,21 +146,24 @@ func (a *SUBController) subs(c *gin.Context) { func (a *SUBController) subJsons(c *gin.Context) { subId := c.Param("subid") _, host, _, _ := a.subService.ResolveRequest(c) - jsonSub, header, err := a.subJsonService.GetJson(subId, host) + jsonSub, header, err := a.subJsonService.GetJson(subId, host, c) // Pass context for HWID registration if err != nil || len(jsonSub) == 0 { c.String(400, "Error!") } else { // Add headers - a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle) + a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, subId) c.String(200, jsonSub) } } // ApplyCommonHeaders sets common HTTP headers for subscription responses including user info, update interval, and profile title. -func (a *SUBController) ApplyCommonHeaders(c *gin.Context, header, updateInterval, profileTitle string) { +// Also adds X-Subscription-ID header so clients can use it as HWID if needed. +func (a *SUBController) ApplyCommonHeaders(c *gin.Context, header, updateInterval, profileTitle, subId string) { c.Writer.Header().Set("Subscription-Userinfo", header) c.Writer.Header().Set("Profile-Update-Interval", updateInterval) c.Writer.Header().Set("Profile-Title", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileTitle))) + // Add subscription ID header so clients can use it as HWID identifier + c.Writer.Header().Set("X-Subscription-ID", subId) } diff --git a/sub/subJsonService.go b/sub/subJsonService.go index 8222491a..ff043dc5 100644 --- a/sub/subJsonService.go +++ b/sub/subJsonService.go @@ -7,6 +7,8 @@ import ( "maps" "strings" + "github.com/gin-gonic/gin" + "github.com/mhsanaei/3x-ui/v2/database" "github.com/mhsanaei/3x-ui/v2/database/model" "github.com/mhsanaei/3x-ui/v2/logger" "github.com/mhsanaei/3x-ui/v2/util/json_util" @@ -71,7 +73,19 @@ func NewSubJsonService(fragment string, noises string, mux string, rules string, } // GetJson generates a JSON subscription configuration for the given subscription ID and host. -func (s *SubJsonService) GetJson(subId string, host string) (string, string, error) { +// If gin.Context is provided, it will also register HWID from HTTP headers. +func (s *SubJsonService) GetJson(subId string, host string, c *gin.Context) (string, string, error) { + // Register HWID from headers if context is provided + if c != nil { + // Try to find client by subId + db := database.GetDB() + var clientEntity *model.ClientEntity + err := db.Where("sub_id = ? AND enable = ?", subId, true).First(&clientEntity).Error + if err == nil && clientEntity != nil { + s.SubService.registerHWIDFromRequest(c, clientEntity) + } + } + inbounds, err := s.SubService.getInboundsBySubId(subId) if err != nil || len(inbounds) == 0 { return "", "", err diff --git a/sub/subService.go b/sub/subService.go index ab746a26..7ed97f25 100644 --- a/sub/subService.go +++ b/sub/subService.go @@ -29,6 +29,9 @@ type SubService struct { inboundService service.InboundService settingService service.SettingService nodeService service.NodeService + hostService service.HostService + clientService service.ClientService + hwidService service.ClientHWIDService } // NewSubService creates a new subscription service with the given configuration. @@ -40,12 +43,34 @@ func NewSubService(showInfo bool, remarkModel string) *SubService { } // GetSubs retrieves subscription links for a given subscription ID and host. -func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.ClientTraffic, error) { +// If gin.Context is provided, it will also register HWID from HTTP headers (x-hwid, x-device-os, etc.). +func (s *SubService) GetSubs(subId string, host string, c *gin.Context) ([]string, int64, xray.ClientTraffic, error) { s.address = host var result []string var traffic xray.ClientTraffic var lastOnline int64 var clientTraffics []xray.ClientTraffic + + // Try to find client by subId in new architecture (ClientEntity) + db := database.GetDB() + var clientEntity *model.ClientEntity + err := db.Where("sub_id = ? AND enable = ?", subId, true).First(&clientEntity).Error + useNewArchitecture := (err == nil && clientEntity != nil) + + if err != nil { + logger.Debugf("GetSubs: Client not found by subId '%s': %v", subId, err) + } else if clientEntity != nil { + logger.Debugf("GetSubs: Found client by subId '%s': clientId=%d, email=%s, hwidEnabled=%v", + subId, clientEntity.Id, clientEntity.Email, clientEntity.HWIDEnabled) + } + + // Register HWID from headers if context is provided and client is found + if c != nil && clientEntity != nil { + s.registerHWIDFromRequest(c, clientEntity) + } else if c != nil { + logger.Debugf("GetSubs: Skipping HWID registration - client not found or context is nil (subId: %s)", subId) + } + inbounds, err := s.getInboundsBySubId(subId) if err != nil { return nil, 0, traffic, err @@ -59,14 +84,8 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C if err != nil { s.datepicker = "gregorian" } + for _, inbound := range inbounds { - clients, err := s.inboundService.GetClients(inbound) - if err != nil { - logger.Error("SubService - GetClients: Unable to get clients from inbound") - } - if clients == nil { - continue - } if len(inbound.Listen) > 0 && inbound.Listen[0] == '@' { listen, port, streamSettings, err := s.getFallbackMaster(inbound.Listen, inbound.StreamSettings) if err == nil { @@ -75,21 +94,48 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C inbound.StreamSettings = streamSettings } } - for _, client := range clients { - if client.Enable && client.SubID == subId { - link := s.getLink(inbound, client.Email) - // Split link by newline to handle multiple links (for multiple nodes) - linkLines := strings.Split(link, "\n") - for _, linkLine := range linkLines { - linkLine = strings.TrimSpace(linkLine) - if linkLine != "" { - result = append(result, linkLine) - } + + if useNewArchitecture { + // New architecture: use ClientEntity data directly + link := s.getLinkWithClient(inbound, clientEntity) + // Split link by newline to handle multiple links (for multiple nodes) + linkLines := strings.Split(link, "\n") + for _, linkLine := range linkLines { + linkLine = strings.TrimSpace(linkLine) + if linkLine != "" { + result = append(result, linkLine) } - ct := s.getClientTraffics(inbound.ClientStats, client.Email) - clientTraffics = append(clientTraffics, ct) - if ct.LastOnline > lastOnline { - lastOnline = ct.LastOnline + } + ct := s.getClientTraffics(inbound.ClientStats, clientEntity.Email) + clientTraffics = append(clientTraffics, ct) + if ct.LastOnline > lastOnline { + lastOnline = ct.LastOnline + } + } else { + // Old architecture: parse clients from Settings + clients, err := s.inboundService.GetClients(inbound) + if err != nil { + logger.Error("SubService - GetClients: Unable to get clients from inbound") + } + if clients == nil { + continue + } + for _, client := range clients { + if client.Enable && client.SubID == subId { + link := s.getLink(inbound, client.Email) + // Split link by newline to handle multiple links (for multiple nodes) + linkLines := strings.Split(link, "\n") + for _, linkLine := range linkLines { + linkLine = strings.TrimSpace(linkLine) + if linkLine != "" { + result = append(result, linkLine) + } + } + ct := s.getClientTraffics(inbound.ClientStats, client.Email) + clientTraffics = append(clientTraffics, ct) + if ct.LastOnline > lastOnline { + lastOnline = ct.LastOnline + } } } } @@ -120,10 +166,45 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C return result, lastOnline, traffic, nil } +// getInboundsBySubId retrieves all inbounds assigned to a client with the given subId. +// New architecture: Find client by subId, then find inbounds through ClientInboundMapping. func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) { db := database.GetDB() + + // First, try to find client by subId in ClientEntity (new architecture) + var client model.ClientEntity + err := db.Where("sub_id = ? AND enable = ?", subId, true).First(&client).Error + if err == nil { + // Found client in new architecture, get inbounds through mapping + var mappings []model.ClientInboundMapping + err = db.Where("client_id = ?", client.Id).Find(&mappings).Error + if err != nil { + return nil, err + } + + if len(mappings) == 0 { + return []*model.Inbound{}, nil + } + + inboundIds := make([]int, len(mappings)) + for i, mapping := range mappings { + inboundIds[i] = mapping.InboundId + } + + var inbounds []*model.Inbound + err = db.Model(model.Inbound{}).Preload("ClientStats"). + Where("id IN ? AND enable = ? AND protocol IN ?", + inboundIds, true, []model.Protocol{model.VMESS, model.VLESS, model.Trojan, model.Shadowsocks}). + Find(&inbounds).Error + if err != nil { + return nil, err + } + return inbounds, nil + } + + // Fallback to old architecture: search in Settings JSON (for backward compatibility) var inbounds []*model.Inbound - err := db.Model(model.Inbound{}).Preload("ClientStats").Where(`id in ( + err = db.Model(model.Inbound{}).Preload("ClientStats").Where(`id in ( SELECT DISTINCT inbounds.id FROM inbounds, JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client @@ -183,13 +264,44 @@ func (s *SubService) getLink(inbound *model.Inbound, email string) string { return "" } -func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string { - if inbound.Protocol != model.VMESS { - return "" +// getLinkWithClient generates a subscription link using ClientEntity data (new architecture) +func (s *SubService) getLinkWithClient(inbound *model.Inbound, client *model.ClientEntity) string { + switch inbound.Protocol { + case "vmess": + return s.genVmessLinkWithClient(inbound, client) + case "vless": + return s.genVlessLinkWithClient(inbound, client) + case "trojan": + return s.genTrojanLinkWithClient(inbound, client) + case "shadowsocks": + return s.genShadowsocksLinkWithClient(inbound, client) + } + return "" +} + +// AddressPort represents an address and port for subscription links +type AddressPort struct { + Address string + Port int // 0 means use inbound.Port +} + +// getAddressesForInbound returns addresses for subscription links. +// Priority: Host (if enabled) > Node addresses > default address +// Returns addresses and ports (0 means use inbound.Port) +func (s *SubService) getAddressesForInbound(inbound *model.Inbound) []AddressPort { + // First, check if there's a Host assigned to this inbound + host, err := s.hostService.GetHostForInbound(inbound.Id) + if err == nil && host != nil && host.Enable { + // Use host address and port + hostPort := host.Port + if hostPort > 0 { + return []AddressPort{{Address: host.Address, Port: hostPort}} + } + return []AddressPort{{Address: host.Address, Port: 0}} // 0 means use inbound.Port } - // Get all nodes for this inbound - var nodeAddresses []string + // Second, get node addresses if in multi-node mode + var nodeAddresses []AddressPort multiMode, _ := s.settingService.GetMultiNodeMode() if multiMode { nodes, err := s.nodeService.GetNodesForInbound(inbound.Id) @@ -198,22 +310,33 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string { for _, node := range nodes { nodeAddr := s.extractNodeHost(node.Address) if nodeAddr != "" { - nodeAddresses = append(nodeAddresses, nodeAddr) + nodeAddresses = append(nodeAddresses, AddressPort{Address: nodeAddr, Port: 0}) } } } } // Fallback to default logic if no nodes found - var defaultAddress string if len(nodeAddresses) == 0 { + var defaultAddress string if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" { defaultAddress = s.address } else { defaultAddress = inbound.Listen } - nodeAddresses = []string{defaultAddress} + nodeAddresses = []AddressPort{{Address: defaultAddress, Port: 0}} } + + return nodeAddresses +} + +func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string { + if inbound.Protocol != model.VMESS { + return "" + } + + // Get addresses (Host > Nodes > Default) + nodeAddresses := s.getAddressesForInbound(inbound) // Base object template (address will be set per node) baseObj := map[string]any{ "v": "2", @@ -351,12 +474,16 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string { } // Generate links for each node address - for _, nodeAddr := range nodeAddresses { + for _, addrPort := range nodeAddresses { obj := make(map[string]any) for k, v := range baseObj { obj[k] = v } - obj["add"] = nodeAddr + obj["add"] = addrPort.Address + // Use port from Host if specified, otherwise use inbound.Port + if addrPort.Port > 0 { + obj["port"] = addrPort.Port + } obj["ps"] = s.genRemark(inbound, email, "") if linkIndex > 0 { @@ -370,37 +497,385 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string { return links } +// genVmessLinkWithClient generates VMESS link using ClientEntity data (new architecture) +func (s *SubService) genVmessLinkWithClient(inbound *model.Inbound, client *model.ClientEntity) string { + if inbound.Protocol != model.VMESS { + return "" + } + + // Get addresses (Host > Nodes > Default) + nodeAddresses := s.getAddressesForInbound(inbound) + // Base object template (address will be set per node) + baseObj := map[string]any{ + "v": "2", + "port": inbound.Port, + "type": "none", + } + var stream map[string]any + json.Unmarshal([]byte(inbound.StreamSettings), &stream) + network, _ := stream["network"].(string) + baseObj["net"] = network + switch network { + case "tcp": + tcp, _ := stream["tcpSettings"].(map[string]any) + header, _ := tcp["header"].(map[string]any) + typeStr, _ := header["type"].(string) + baseObj["type"] = typeStr + if typeStr == "http" { + request := header["request"].(map[string]any) + requestPath, _ := request["path"].([]any) + baseObj["path"] = requestPath[0].(string) + headers, _ := request["headers"].(map[string]any) + baseObj["host"] = searchHost(headers) + } + case "kcp": + kcp, _ := stream["kcpSettings"].(map[string]any) + header, _ := kcp["header"].(map[string]any) + baseObj["type"], _ = header["type"].(string) + baseObj["path"], _ = kcp["seed"].(string) + case "ws": + ws, _ := stream["wsSettings"].(map[string]any) + baseObj["path"] = ws["path"].(string) + if host, ok := ws["host"].(string); ok && len(host) > 0 { + baseObj["host"] = host + } else { + headers, _ := ws["headers"].(map[string]any) + baseObj["host"] = searchHost(headers) + } + case "grpc": + grpc, _ := stream["grpcSettings"].(map[string]any) + baseObj["path"] = grpc["serviceName"].(string) + baseObj["authority"] = grpc["authority"].(string) + if grpc["multiMode"].(bool) { + baseObj["type"] = "multi" + } + case "httpupgrade": + httpupgrade, _ := stream["httpupgradeSettings"].(map[string]any) + baseObj["path"] = httpupgrade["path"].(string) + if host, ok := httpupgrade["host"].(string); ok && len(host) > 0 { + baseObj["host"] = host + } else { + headers, _ := httpupgrade["headers"].(map[string]any) + baseObj["host"] = searchHost(headers) + } + case "xhttp": + xhttp, _ := stream["xhttpSettings"].(map[string]any) + baseObj["path"] = xhttp["path"].(string) + if host, ok := xhttp["host"].(string); ok && len(host) > 0 { + baseObj["host"] = host + } else { + headers, _ := xhttp["headers"].(map[string]any) + baseObj["host"] = searchHost(headers) + } + baseObj["mode"] = xhttp["mode"].(string) + } + security, _ := stream["security"].(string) + baseObj["tls"] = security + if security == "tls" { + tlsSetting, _ := stream["tlsSettings"].(map[string]any) + alpns, _ := tlsSetting["alpn"].([]any) + if len(alpns) > 0 { + var alpn []string + for _, a := range alpns { + alpn = append(alpn, a.(string)) + } + baseObj["alpn"] = strings.Join(alpn, ",") + } + if sniValue, ok := searchKey(tlsSetting, "serverName"); ok { + baseObj["sni"], _ = sniValue.(string) + } + + tlsSettings, _ := searchKey(tlsSetting, "settings") + if tlsSetting != nil { + if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok { + baseObj["fp"], _ = fpValue.(string) + } + if insecure, ok := searchKey(tlsSettings, "allowInsecure"); ok { + baseObj["allowInsecure"], _ = insecure.(bool) + } + } + } + + // Use ClientEntity data directly + baseObj["id"] = client.UUID + baseObj["scy"] = client.Security + + externalProxies, _ := stream["externalProxy"].([]any) + + // Generate links for each node address (or external proxy) + links := "" + linkIndex := 0 + + // First, handle external proxies if any + if len(externalProxies) > 0 { + for _, externalProxy := range externalProxies { + ep, _ := externalProxy.(map[string]any) + newSecurity, _ := ep["forceTls"].(string) + newObj := map[string]any{} + for key, value := range baseObj { + if !(newSecurity == "none" && (key == "alpn" || key == "sni" || key == "fp" || key == "allowInsecure")) { + newObj[key] = value + } + } + newObj["ps"] = s.genRemark(inbound, client.Email, ep["remark"].(string)) + newObj["add"] = ep["dest"].(string) + newObj["port"] = int(ep["port"].(float64)) + + if newSecurity != "same" { + newObj["tls"] = newSecurity + } + if linkIndex > 0 { + links += "\n" + } + jsonStr, _ := json.MarshalIndent(newObj, "", " ") + links += "vmess://" + base64.StdEncoding.EncodeToString(jsonStr) + linkIndex++ + } + return links + } + + // Generate links for each node address + for _, addrPort := range nodeAddresses { + obj := make(map[string]any) + for k, v := range baseObj { + obj[k] = v + } + obj["add"] = addrPort.Address + // Use port from Host if specified, otherwise use inbound.Port + if addrPort.Port > 0 { + obj["port"] = addrPort.Port + } + obj["ps"] = s.genRemark(inbound, client.Email, "") + + if linkIndex > 0 { + links += "\n" + } + jsonStr, _ := json.MarshalIndent(obj, "", " ") + links += "vmess://" + base64.StdEncoding.EncodeToString(jsonStr) + linkIndex++ + } + + return links +} + +// genVlessLinkWithClient generates VLESS link using ClientEntity data (new architecture) +func (s *SubService) genVlessLinkWithClient(inbound *model.Inbound, client *model.ClientEntity) string { + if inbound.Protocol != model.VLESS { + return "" + } + + // Get addresses (Host > Nodes > Default) + nodeAddresses := s.getAddressesForInbound(inbound) + var stream map[string]any + json.Unmarshal([]byte(inbound.StreamSettings), &stream) + uuid := client.UUID + port := inbound.Port + streamNetwork := stream["network"].(string) + params := make(map[string]string) + params["type"] = streamNetwork + + // Add encryption parameter for VLESS from inbound settings + var settings map[string]any + json.Unmarshal([]byte(inbound.Settings), &settings) + if encryption, ok := settings["encryption"].(string); ok { + params["encryption"] = encryption + } + + switch streamNetwork { + case "tcp": + tcp, _ := stream["tcpSettings"].(map[string]any) + header, _ := tcp["header"].(map[string]any) + typeStr, _ := header["type"].(string) + if typeStr == "http" { + request := header["request"].(map[string]any) + requestPath, _ := request["path"].([]any) + params["path"] = requestPath[0].(string) + headers, _ := request["headers"].(map[string]any) + params["host"] = searchHost(headers) + params["headerType"] = "http" + } + case "kcp": + kcp, _ := stream["kcpSettings"].(map[string]any) + header, _ := kcp["header"].(map[string]any) + params["headerType"] = header["type"].(string) + params["seed"] = kcp["seed"].(string) + case "ws": + ws, _ := stream["wsSettings"].(map[string]any) + params["path"] = ws["path"].(string) + if host, ok := ws["host"].(string); ok && len(host) > 0 { + params["host"] = host + } else { + headers, _ := ws["headers"].(map[string]any) + params["host"] = searchHost(headers) + } + case "grpc": + grpc, _ := stream["grpcSettings"].(map[string]any) + params["serviceName"] = grpc["serviceName"].(string) + params["authority"], _ = grpc["authority"].(string) + if grpc["multiMode"].(bool) { + params["mode"] = "multi" + } + case "httpupgrade": + httpupgrade, _ := stream["httpupgradeSettings"].(map[string]any) + params["path"] = httpupgrade["path"].(string) + if host, ok := httpupgrade["host"].(string); ok && len(host) > 0 { + params["host"] = host + } else { + headers, _ := httpupgrade["headers"].(map[string]any) + params["host"] = searchHost(headers) + } + case "xhttp": + xhttp, _ := stream["xhttpSettings"].(map[string]any) + params["path"] = xhttp["path"].(string) + if host, ok := xhttp["host"].(string); ok && len(host) > 0 { + params["host"] = host + } else { + headers, _ := xhttp["headers"].(map[string]any) + params["host"] = searchHost(headers) + } + params["mode"] = xhttp["mode"].(string) + } + security, _ := stream["security"].(string) + if security == "tls" { + params["security"] = "tls" + tlsSetting, _ := stream["tlsSettings"].(map[string]any) + alpns, _ := tlsSetting["alpn"].([]any) + var alpn []string + for _, a := range alpns { + alpn = append(alpn, a.(string)) + } + if len(alpn) > 0 { + params["alpn"] = strings.Join(alpn, ",") + } + if sniValue, ok := searchKey(tlsSetting, "serverName"); ok { + params["sni"], _ = sniValue.(string) + } + + tlsSettings, _ := searchKey(tlsSetting, "settings") + if tlsSetting != nil { + if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok { + params["fp"], _ = fpValue.(string) + } + if insecure, ok := searchKey(tlsSettings, "allowInsecure"); ok { + if insecure.(bool) { + params["allowInsecure"] = "1" + } + } + } + + if streamNetwork == "tcp" && len(client.Flow) > 0 { + params["flow"] = client.Flow + } + } + + if security == "reality" { + params["security"] = "reality" + realitySetting, _ := stream["realitySettings"].(map[string]any) + realitySettings, _ := searchKey(realitySetting, "settings") + if realitySetting != nil { + if sniValue, ok := searchKey(realitySetting, "serverNames"); ok { + sNames, _ := sniValue.([]any) + params["sni"] = sNames[random.Num(len(sNames))].(string) + } + if pbkValue, ok := searchKey(realitySettings, "publicKey"); ok { + params["pbk"], _ = pbkValue.(string) + } + if sidValue, ok := searchKey(realitySetting, "shortIds"); ok { + shortIds, _ := sidValue.([]any) + params["sid"] = shortIds[random.Num(len(shortIds))].(string) + } + if fpValue, ok := searchKey(realitySettings, "fingerprint"); ok { + if fp, ok := fpValue.(string); ok && len(fp) > 0 { + params["fp"] = fp + } + } + if pqvValue, ok := searchKey(realitySettings, "mldsa65Verify"); ok { + if pqv, ok := pqvValue.(string); ok && len(pqv) > 0 { + params["pqv"] = pqv + } + } + params["spx"] = "/" + random.Seq(15) + } + + if streamNetwork == "tcp" && len(client.Flow) > 0 { + params["flow"] = client.Flow + } + } + + if security != "tls" && security != "reality" { + params["security"] = "none" + } + + externalProxies, _ := stream["externalProxy"].([]any) + + // Generate links for each node address (or external proxy) + var initialCapacity int + if len(externalProxies) > 0 { + initialCapacity = len(externalProxies) + } else { + initialCapacity = len(nodeAddresses) + } + links := make([]string, 0, initialCapacity) + + // First, handle external proxies if any + if len(externalProxies) > 0 { + for _, externalProxy := range externalProxies { + ep, _ := externalProxy.(map[string]any) + newSecurity, _ := ep["forceTls"].(string) + dest, _ := ep["dest"].(string) + epPort := int(ep["port"].(float64)) + link := fmt.Sprintf("vless://%s@%s:%d", uuid, dest, epPort) + + if newSecurity != "same" { + params["security"] = newSecurity + } else { + params["security"] = security + } + url, _ := url.Parse(link) + q := url.Query() + + for k, v := range params { + if !(newSecurity == "none" && (k == "alpn" || k == "sni" || k == "fp" || k == "allowInsecure")) { + q.Add(k, v) + } + } + + url.RawQuery = q.Encode() + url.Fragment = s.genRemark(inbound, client.Email, ep["remark"].(string)) + links = append(links, url.String()) + } + return strings.Join(links, "\n") + } + + // Generate links for each node address + for _, addrPort := range nodeAddresses { + linkPort := port + if addrPort.Port > 0 { + linkPort = addrPort.Port + } + link := fmt.Sprintf("vless://%s@%s:%d", uuid, addrPort.Address, linkPort) + url, _ := url.Parse(link) + q := url.Query() + + for k, v := range params { + q.Add(k, v) + } + + url.RawQuery = q.Encode() + url.Fragment = s.genRemark(inbound, client.Email, "") + links = append(links, url.String()) + } + + return strings.Join(links, "\n") +} + func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { if inbound.Protocol != model.VLESS { return "" } - // Get all nodes for this inbound - var nodeAddresses []string - multiMode, _ := s.settingService.GetMultiNodeMode() - if multiMode { - nodes, err := s.nodeService.GetNodesForInbound(inbound.Id) - if err == nil && len(nodes) > 0 { - // Extract addresses from all nodes - for _, node := range nodes { - nodeAddr := s.extractNodeHost(node.Address) - if nodeAddr != "" { - nodeAddresses = append(nodeAddresses, nodeAddr) - } - } - } - } - - // Fallback to default logic if no nodes found - var defaultAddress string - if len(nodeAddresses) == 0 { - if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" { - defaultAddress = s.address - } else { - defaultAddress = inbound.Listen - } - nodeAddresses = []string{defaultAddress} - } + // Get addresses (Host > Nodes > Default) + nodeAddresses := s.getAddressesForInbound(inbound) var stream map[string]any json.Unmarshal([]byte(inbound.StreamSettings), &stream) clients, _ := s.inboundService.GetClients(inbound) @@ -595,8 +1070,13 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { } // Generate links for each node address - for _, nodeAddr := range nodeAddresses { - link := fmt.Sprintf("vless://%s@%s:%d", uuid, nodeAddr, port) + for _, addrPort := range nodeAddresses { + // Use port from Host if specified, otherwise use inbound.Port + linkPort := port + if addrPort.Port > 0 { + linkPort = addrPort.Port + } + link := fmt.Sprintf("vless://%s@%s:%d", uuid, addrPort.Address, linkPort) url, _ := url.Parse(link) q := url.Query() @@ -615,37 +1095,215 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { return strings.Join(links, "\n") } +// genTrojanLinkWithClient generates Trojan link using ClientEntity data (new architecture) +func (s *SubService) genTrojanLinkWithClient(inbound *model.Inbound, client *model.ClientEntity) string { + if inbound.Protocol != model.Trojan { + return "" + } + + // Get addresses (Host > Nodes > Default) + nodeAddresses := s.getAddressesForInbound(inbound) + var stream map[string]any + json.Unmarshal([]byte(inbound.StreamSettings), &stream) + password := client.Password + port := inbound.Port + streamNetwork := stream["network"].(string) + params := make(map[string]string) + params["type"] = streamNetwork + + switch streamNetwork { + case "tcp": + tcp, _ := stream["tcpSettings"].(map[string]any) + header, _ := tcp["header"].(map[string]any) + typeStr, _ := header["type"].(string) + if typeStr == "http" { + request := header["request"].(map[string]any) + requestPath, _ := request["path"].([]any) + params["path"] = requestPath[0].(string) + headers, _ := request["headers"].(map[string]any) + params["host"] = searchHost(headers) + params["headerType"] = "http" + } + case "kcp": + kcp, _ := stream["kcpSettings"].(map[string]any) + header, _ := kcp["header"].(map[string]any) + params["headerType"] = header["type"].(string) + params["seed"] = kcp["seed"].(string) + case "ws": + ws, _ := stream["wsSettings"].(map[string]any) + params["path"] = ws["path"].(string) + if host, ok := ws["host"].(string); ok && len(host) > 0 { + params["host"] = host + } else { + headers, _ := ws["headers"].(map[string]any) + params["host"] = searchHost(headers) + } + case "grpc": + grpc, _ := stream["grpcSettings"].(map[string]any) + params["serviceName"] = grpc["serviceName"].(string) + params["authority"], _ = grpc["authority"].(string) + if grpc["multiMode"].(bool) { + params["mode"] = "multi" + } + case "httpupgrade": + httpupgrade, _ := stream["httpupgradeSettings"].(map[string]any) + params["path"] = httpupgrade["path"].(string) + if host, ok := httpupgrade["host"].(string); ok && len(host) > 0 { + params["host"] = host + } else { + headers, _ := httpupgrade["headers"].(map[string]any) + params["host"] = searchHost(headers) + } + case "xhttp": + xhttp, _ := stream["xhttpSettings"].(map[string]any) + params["path"] = xhttp["path"].(string) + if host, ok := xhttp["host"].(string); ok && len(host) > 0 { + params["host"] = host + } else { + headers, _ := xhttp["headers"].(map[string]any) + params["host"] = searchHost(headers) + } + params["mode"] = xhttp["mode"].(string) + } + security, _ := stream["security"].(string) + if security == "tls" { + params["security"] = "tls" + tlsSetting, _ := stream["tlsSettings"].(map[string]any) + alpns, _ := tlsSetting["alpn"].([]any) + var alpn []string + for _, a := range alpns { + alpn = append(alpn, a.(string)) + } + if len(alpn) > 0 { + params["alpn"] = strings.Join(alpn, ",") + } + if sniValue, ok := searchKey(tlsSetting, "serverName"); ok { + params["sni"], _ = sniValue.(string) + } + + tlsSettings, _ := searchKey(tlsSetting, "settings") + if tlsSetting != nil { + if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok { + params["fp"], _ = fpValue.(string) + } + if insecure, ok := searchKey(tlsSettings, "allowInsecure"); ok { + if insecure.(bool) { + params["allowInsecure"] = "1" + } + } + } + } + + if security == "reality" { + params["security"] = "reality" + realitySetting, _ := stream["realitySettings"].(map[string]any) + realitySettings, _ := searchKey(realitySetting, "settings") + if realitySetting != nil { + if sniValue, ok := searchKey(realitySetting, "serverNames"); ok { + sNames, _ := sniValue.([]any) + params["sni"] = sNames[random.Num(len(sNames))].(string) + } + if pbkValue, ok := searchKey(realitySettings, "publicKey"); ok { + params["pbk"], _ = pbkValue.(string) + } + if sidValue, ok := searchKey(realitySetting, "shortIds"); ok { + shortIds, _ := sidValue.([]any) + params["sid"] = shortIds[random.Num(len(shortIds))].(string) + } + if fpValue, ok := searchKey(realitySettings, "fingerprint"); ok { + if fp, ok := fpValue.(string); ok && len(fp) > 0 { + params["fp"] = fp + } + } + if pqvValue, ok := searchKey(realitySettings, "mldsa65Verify"); ok { + if pqv, ok := pqvValue.(string); ok && len(pqv) > 0 { + params["pqv"] = pqv + } + } + params["spx"] = "/" + random.Seq(15) + } + + if streamNetwork == "tcp" && len(client.Flow) > 0 { + params["flow"] = client.Flow + } + } + + if security != "tls" && security != "reality" { + params["security"] = "none" + } + + externalProxies, _ := stream["externalProxy"].([]any) + + links := "" + linkIndex := 0 + + if len(externalProxies) > 0 { + for _, externalProxy := range externalProxies { + ep, _ := externalProxy.(map[string]any) + newSecurity, _ := ep["forceTls"].(string) + dest, _ := ep["dest"].(string) + epPort := int(ep["port"].(float64)) + link := fmt.Sprintf("trojan://%s@%s:%d", password, dest, epPort) + + if newSecurity != "same" { + params["security"] = newSecurity + } else { + params["security"] = security + } + url, _ := url.Parse(link) + q := url.Query() + + for k, v := range params { + if !(newSecurity == "none" && (k == "alpn" || k == "sni" || k == "fp" || k == "allowInsecure")) { + q.Add(k, v) + } + } + + url.RawQuery = q.Encode() + url.Fragment = s.genRemark(inbound, client.Email, ep["remark"].(string)) + + if linkIndex > 0 { + links += "\n" + } + links += url.String() + linkIndex++ + } + return links + } + + for _, addrPort := range nodeAddresses { + linkPort := port + if addrPort.Port > 0 { + linkPort = addrPort.Port + } + link := fmt.Sprintf("trojan://%s@%s:%d", password, addrPort.Address, linkPort) + url, _ := url.Parse(link) + q := url.Query() + + for k, v := range params { + q.Add(k, v) + } + + url.RawQuery = q.Encode() + url.Fragment = s.genRemark(inbound, client.Email, "") + + if linkIndex > 0 { + links += "\n" + } + links += url.String() + linkIndex++ + } + + return links +} + func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string { if inbound.Protocol != model.Trojan { return "" } - // Get all nodes for this inbound - var nodeAddresses []string - multiMode, _ := s.settingService.GetMultiNodeMode() - if multiMode { - nodes, err := s.nodeService.GetNodesForInbound(inbound.Id) - if err == nil && len(nodes) > 0 { - // Extract addresses from all nodes - for _, node := range nodes { - nodeAddr := s.extractNodeHost(node.Address) - if nodeAddr != "" { - nodeAddresses = append(nodeAddresses, nodeAddr) - } - } - } - } - - // Fallback to default logic if no nodes found - var defaultAddress string - if len(nodeAddresses) == 0 { - if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" { - defaultAddress = s.address - } else { - defaultAddress = inbound.Listen - } - nodeAddresses = []string{defaultAddress} - } + // Get addresses (Host > Nodes > Default) + nodeAddresses := s.getAddressesForInbound(inbound) var stream map[string]any json.Unmarshal([]byte(inbound.StreamSettings), &stream) clients, _ := s.inboundService.GetClients(inbound) @@ -827,8 +1485,13 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string } // Generate links for each node address - for _, nodeAddr := range nodeAddresses { - link := fmt.Sprintf("trojan://%s@%s:%d", password, nodeAddr, port) + for _, addrPort := range nodeAddresses { + // Use port from Host if specified, otherwise use inbound.Port + linkPort := port + if addrPort.Port > 0 { + linkPort = addrPort.Port + } + link := fmt.Sprintf("trojan://%s@%s:%d", password, addrPort.Address, linkPort) url, _ := url.Parse(link) q := url.Query() @@ -851,37 +1514,186 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string return links } +// genShadowsocksLinkWithClient generates Shadowsocks link using ClientEntity data (new architecture) +func (s *SubService) genShadowsocksLinkWithClient(inbound *model.Inbound, client *model.ClientEntity) string { + if inbound.Protocol != model.Shadowsocks { + return "" + } + + // Get addresses (Host > Nodes > Default) + nodeAddresses := s.getAddressesForInbound(inbound) + var stream map[string]any + json.Unmarshal([]byte(inbound.StreamSettings), &stream) + + var settings map[string]any + json.Unmarshal([]byte(inbound.Settings), &settings) + inboundPassword := settings["password"].(string) + method := settings["method"].(string) + streamNetwork := stream["network"].(string) + params := make(map[string]string) + params["type"] = streamNetwork + + switch streamNetwork { + case "tcp": + tcp, _ := stream["tcpSettings"].(map[string]any) + header, _ := tcp["header"].(map[string]any) + typeStr, _ := header["type"].(string) + if typeStr == "http" { + request := header["request"].(map[string]any) + requestPath, _ := request["path"].([]any) + params["path"] = requestPath[0].(string) + headers, _ := request["headers"].(map[string]any) + params["host"] = searchHost(headers) + params["headerType"] = "http" + } + case "kcp": + kcp, _ := stream["kcpSettings"].(map[string]any) + header, _ := kcp["header"].(map[string]any) + params["headerType"] = header["type"].(string) + params["seed"] = kcp["seed"].(string) + case "ws": + ws, _ := stream["wsSettings"].(map[string]any) + params["path"] = ws["path"].(string) + if host, ok := ws["host"].(string); ok && len(host) > 0 { + params["host"] = host + } else { + headers, _ := ws["headers"].(map[string]any) + params["host"] = searchHost(headers) + } + case "grpc": + grpc, _ := stream["grpcSettings"].(map[string]any) + params["serviceName"] = grpc["serviceName"].(string) + params["authority"], _ = grpc["authority"].(string) + if grpc["multiMode"].(bool) { + params["mode"] = "multi" + } + case "httpupgrade": + httpupgrade, _ := stream["httpupgradeSettings"].(map[string]any) + params["path"] = httpupgrade["path"].(string) + if host, ok := httpupgrade["host"].(string); ok && len(host) > 0 { + params["host"] = host + } else { + headers, _ := httpupgrade["headers"].(map[string]any) + params["host"] = searchHost(headers) + } + case "xhttp": + xhttp, _ := stream["xhttpSettings"].(map[string]any) + params["path"] = xhttp["path"].(string) + if host, ok := xhttp["host"].(string); ok && len(host) > 0 { + params["host"] = host + } else { + headers, _ := xhttp["headers"].(map[string]any) + params["host"] = searchHost(headers) + } + params["mode"] = xhttp["mode"].(string) + } + + security, _ := stream["security"].(string) + if security == "tls" { + params["security"] = "tls" + tlsSetting, _ := stream["tlsSettings"].(map[string]any) + alpns, _ := tlsSetting["alpn"].([]any) + var alpn []string + for _, a := range alpns { + alpn = append(alpn, a.(string)) + } + if len(alpn) > 0 { + params["alpn"] = strings.Join(alpn, ",") + } + if sniValue, ok := searchKey(tlsSetting, "serverName"); ok { + params["sni"], _ = sniValue.(string) + } + + tlsSettings, _ := searchKey(tlsSetting, "settings") + if tlsSetting != nil { + if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok { + params["fp"], _ = fpValue.(string) + } + if insecure, ok := searchKey(tlsSettings, "allowInsecure"); ok { + if insecure.(bool) { + params["allowInsecure"] = "1" + } + } + } + } + + encPart := fmt.Sprintf("%s:%s", method, client.Password) + if method[0] == '2' { + encPart = fmt.Sprintf("%s:%s:%s", method, inboundPassword, client.Password) + } + + externalProxies, _ := stream["externalProxy"].([]any) + + links := "" + linkIndex := 0 + + if len(externalProxies) > 0 { + for _, externalProxy := range externalProxies { + ep, _ := externalProxy.(map[string]any) + newSecurity, _ := ep["forceTls"].(string) + dest, _ := ep["dest"].(string) + epPort := int(ep["port"].(float64)) + link := fmt.Sprintf("ss://%s@%s:%d", base64.StdEncoding.EncodeToString([]byte(encPart)), dest, epPort) + + if newSecurity != "same" { + params["security"] = newSecurity + } else { + params["security"] = security + } + url, _ := url.Parse(link) + q := url.Query() + + for k, v := range params { + if !(newSecurity == "none" && (k == "alpn" || k == "sni" || k == "fp" || k == "allowInsecure")) { + q.Add(k, v) + } + } + + url.RawQuery = q.Encode() + url.Fragment = s.genRemark(inbound, client.Email, ep["remark"].(string)) + + if linkIndex > 0 { + links += "\n" + } + links += url.String() + linkIndex++ + } + return links + } + + for _, addrPort := range nodeAddresses { + linkPort := inbound.Port + if addrPort.Port > 0 { + linkPort = addrPort.Port + } + link := fmt.Sprintf("ss://%s@%s:%d", base64.StdEncoding.EncodeToString([]byte(encPart)), addrPort.Address, linkPort) + url, _ := url.Parse(link) + q := url.Query() + + for k, v := range params { + q.Add(k, v) + } + + url.RawQuery = q.Encode() + url.Fragment = s.genRemark(inbound, client.Email, "") + + if linkIndex > 0 { + links += "\n" + } + links += url.String() + linkIndex++ + } + + return links +} + func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) string { if inbound.Protocol != model.Shadowsocks { return "" } - // Get all nodes for this inbound - var nodeAddresses []string - multiMode, _ := s.settingService.GetMultiNodeMode() - if multiMode { - nodes, err := s.nodeService.GetNodesForInbound(inbound.Id) - if err == nil && len(nodes) > 0 { - // Extract addresses from all nodes - for _, node := range nodes { - nodeAddr := s.extractNodeHost(node.Address) - if nodeAddr != "" { - nodeAddresses = append(nodeAddresses, nodeAddr) - } - } - } - } - - // Fallback to default logic if no nodes found - var defaultAddress string - if len(nodeAddresses) == 0 { - if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" { - defaultAddress = s.address - } else { - defaultAddress = inbound.Listen - } - nodeAddresses = []string{defaultAddress} - } + // Get addresses (Host > Nodes > Default) + nodeAddresses := s.getAddressesForInbound(inbound) var stream map[string]any json.Unmarshal([]byte(inbound.StreamSettings), &stream) clients, _ := s.inboundService.GetClients(inbound) @@ -1034,8 +1846,13 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st } // Generate links for each node address - for _, nodeAddr := range nodeAddresses { - link := fmt.Sprintf("ss://%s@%s:%d", base64.StdEncoding.EncodeToString([]byte(encPart)), nodeAddr, inbound.Port) + for _, addrPort := range nodeAddresses { + // Use port from Host if specified, otherwise use inbound.Port + linkPort := inbound.Port + if addrPort.Port > 0 { + linkPort = addrPort.Port + } + link := fmt.Sprintf("ss://%s@%s:%d", base64.StdEncoding.EncodeToString([]byte(encPart)), addrPort.Address, linkPort) url, _ := url.Parse(link) q := url.Query() @@ -1386,3 +2203,87 @@ func (s *SubService) extractNodeHost(nodeAddress string) string { } return host } + +// registerHWIDFromRequest registers HWID from HTTP headers in the request context. +// This method reads HWID and device metadata from headers and calls RegisterHWIDFromHeaders. +func (s *SubService) registerHWIDFromRequest(c *gin.Context, clientEntity *model.ClientEntity) { + logger.Debugf("registerHWIDFromRequest called for client %d (subId: %s, email: %s, hwidEnabled: %v)", + clientEntity.Id, clientEntity.SubID, clientEntity.Email, clientEntity.HWIDEnabled) + + // Check HWID mode - only register in client_header mode + settingService := service.SettingService{} + hwidMode, err := settingService.GetHwidMode() + if err != nil { + logger.Debugf("Failed to get hwidMode setting: %v", err) + return + } + logger.Debugf("Current hwidMode: %s", hwidMode) + + // Only register in client_header mode + if hwidMode != "client_header" { + logger.Debugf("HWID registration skipped: hwidMode is '%s' (not 'client_header') for client %d (subId: %s)", + hwidMode, clientEntity.Id, clientEntity.SubID) + return + } + + // Check if client has HWID tracking enabled + if !clientEntity.HWIDEnabled { + logger.Debugf("HWID registration skipped: HWID tracking disabled for client %d (subId: %s, email: %s)", + clientEntity.Id, clientEntity.SubID, clientEntity.Email) + return + } + + // Read HWID from headers (required) + hwid := c.GetHeader("x-hwid") + if hwid == "" { + // Try alternative header name (case-insensitive) + hwid = c.GetHeader("X-HWID") + } + if hwid == "" { + // No HWID header - mark as "unknown" device, don't register + // In client_header mode, we don't auto-generate HWID + logger.Debugf("No x-hwid header provided for client %d (subId: %s, email: %s) - HWID not registered", + clientEntity.Id, clientEntity.SubID, clientEntity.Email) + return + } + + // Read device metadata from headers (optional) + deviceOS := c.GetHeader("x-device-os") + if deviceOS == "" { + deviceOS = c.GetHeader("X-Device-OS") + } + deviceModel := c.GetHeader("x-device-model") + if deviceModel == "" { + deviceModel = c.GetHeader("X-Device-Model") + } + osVersion := c.GetHeader("x-ver-os") + if osVersion == "" { + osVersion = c.GetHeader("X-Ver-OS") + } + userAgent := c.GetHeader("User-Agent") + ipAddress := c.ClientIP() + + // Register HWID + hwidService := service.ClientHWIDService{} + hwidRecord, err := hwidService.RegisterHWIDFromHeaders(clientEntity.Id, hwid, deviceOS, deviceModel, osVersion, ipAddress, userAgent) + if err != nil { + // Check if error is HWID limit exceeded + if strings.Contains(err.Error(), "HWID limit exceeded") { + // Log as warning - this is an expected error when limit is reached + logger.Warningf("HWID limit exceeded for client %d (subId: %s, email: %s): %v", + clientEntity.Id, clientEntity.SubID, clientEntity.Email, err) + // Note: We still allow the subscription request to proceed + // The client application should handle this error and inform the user + // that they need to remove an existing device or contact admin to increase limit + } else { + // Other errors - log as warning but don't fail subscription + logger.Warningf("Failed to register HWID for client %d (subId: %s): %v", clientEntity.Id, clientEntity.SubID, err) + } + // HWID registration failure should not block subscription access + // The subscription will still be returned, but HWID won't be registered + } else if hwidRecord != nil { + // Successfully registered HWID + logger.Debugf("Successfully registered HWID for client %d (subId: %s, email: %s, hwid: %s, hwidId: %d)", + clientEntity.Id, clientEntity.SubID, clientEntity.Email, hwid, hwidRecord.Id) + } +} diff --git a/web/assets/js/model/inbound.js b/web/assets/js/model/inbound.js index 9aa05ed3..16ee5d34 100644 --- a/web/assets/js/model/inbound.js +++ b/web/assets/js/model/inbound.js @@ -1902,7 +1902,7 @@ Inbound.Settings = class extends XrayCommonClass { Inbound.VmessSettings = class extends Inbound.Settings { constructor(protocol, - vmesses = [new Inbound.VmessSettings.VMESS()]) { + vmesses = []) { super(protocol); this.vmesses = vmesses; } @@ -2018,7 +2018,7 @@ Inbound.VmessSettings.VMESS = class extends XrayCommonClass { Inbound.VLESSSettings = class extends Inbound.Settings { constructor( protocol, - vlesses = [new Inbound.VLESSSettings.VLESS()], + vlesses = [], decryption = "none", encryption = "none", fallbacks = [], @@ -2208,7 +2208,7 @@ Inbound.VLESSSettings.Fallback = class extends XrayCommonClass { Inbound.TrojanSettings = class extends Inbound.Settings { constructor(protocol, - trojans = [new Inbound.TrojanSettings.Trojan()], + trojans = [], fallbacks = [],) { super(protocol); this.trojans = trojans; @@ -2373,7 +2373,7 @@ Inbound.ShadowsocksSettings = class extends Inbound.Settings { method = SSMethods.BLAKE3_AES_256_GCM, password = RandomUtil.randomShadowsocksPassword(), network = 'tcp,udp', - shadowsockses = [new Inbound.ShadowsocksSettings.Shadowsocks()], + shadowsockses = [], ivCheck = false, ) { super(protocol); diff --git a/web/assets/js/model/setting.js b/web/assets/js/model/setting.js index 3446832d..fbf1233b 100644 --- a/web/assets/js/model/setting.js +++ b/web/assets/js/model/setting.js @@ -74,6 +74,12 @@ class AllSetting { // Multi-node mode settings this.multiNodeMode = false; // Multi-node mode setting + + // HWID tracking mode + // "off" = HWID tracking disabled + // "client_header" = HWID provided by client via x-hwid header (default, recommended) + // "legacy_fingerprint" = deprecated fingerprint-based HWID generation (deprecated, for backward compatibility only) + this.hwidMode = "client_header"; // HWID tracking mode if (data == null) { return @@ -90,6 +96,18 @@ class AllSetting { } else { this.multiNodeMode = false; } + + // Ensure hwidMode is valid string (default to "client_header" if invalid) + if (this.hwidMode === undefined || this.hwidMode === null) { + this.hwidMode = "client_header"; + } else if (typeof this.hwidMode !== 'string') { + this.hwidMode = String(this.hwidMode); + } + // Validate hwidMode value + const validHwidModes = ["off", "client_header", "legacy_fingerprint"]; + if (!validHwidModes.includes(this.hwidMode)) { + this.hwidMode = "client_header"; // Default to client_header if invalid + } } equals(other) { diff --git a/web/controller/client.go b/web/controller/client.go new file mode 100644 index 00000000..a1417c9d --- /dev/null +++ b/web/controller/client.go @@ -0,0 +1,274 @@ +// Package controller provides HTTP handlers for client management. +package controller + +import ( + "bytes" + "encoding/json" + "io" + "strconv" + + "github.com/mhsanaei/3x-ui/v2/database/model" + "github.com/mhsanaei/3x-ui/v2/logger" + "github.com/mhsanaei/3x-ui/v2/web/service" + "github.com/mhsanaei/3x-ui/v2/web/session" + + "github.com/gin-gonic/gin" +) + +// ClientController handles HTTP requests related to client management. +type ClientController struct { + clientService service.ClientService + xrayService service.XrayService +} + +// NewClientController creates a new ClientController and sets up its routes. +func NewClientController(g *gin.RouterGroup) *ClientController { + a := &ClientController{ + clientService: service.ClientService{}, + xrayService: service.XrayService{}, + } + a.initRouter(g) + return a +} + +// initRouter initializes the routes for client-related operations. +func (a *ClientController) initRouter(g *gin.RouterGroup) { + g.GET("/list", a.getClients) + g.GET("/get/:id", a.getClient) + g.POST("/add", a.addClient) + g.POST("/update/:id", a.updateClient) + g.POST("/del/:id", a.deleteClient) +} + +// getClients retrieves the list of all clients for the current user. +func (a *ClientController) getClients(c *gin.Context) { + user := session.GetLoginUser(c) + clients, err := a.clientService.GetClients(user.Id) + if err != nil { + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + jsonObj(c, clients, nil) +} + +// getClient retrieves a specific client by its ID. +func (a *ClientController) getClient(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + jsonMsg(c, "Invalid client ID", err) + return + } + user := session.GetLoginUser(c) + client, err := a.clientService.GetClient(id) + if err != nil { + jsonMsg(c, "Failed to get client", err) + return + } + if client.UserId != user.Id { + jsonMsg(c, "Client not found or access denied", nil) + return + } + jsonObj(c, client, nil) +} + +// addClient creates a new client. +func (a *ClientController) addClient(c *gin.Context) { + user := session.GetLoginUser(c) + + // Extract inboundIds from JSON or form data + var inboundIdsFromJSON []int + var hasInboundIdsInJSON bool + + if c.ContentType() == "application/json" { + // Read raw body to extract inboundIds + bodyBytes, err := c.GetRawData() + if err == nil && len(bodyBytes) > 0 { + // Parse JSON to extract inboundIds + var jsonData map[string]interface{} + if err := json.Unmarshal(bodyBytes, &jsonData); err == nil { + // Check for inboundIds array + if inboundIdsVal, ok := jsonData["inboundIds"]; ok { + hasInboundIdsInJSON = true + if inboundIdsArray, ok := inboundIdsVal.([]interface{}); ok { + for _, val := range inboundIdsArray { + if num, ok := val.(float64); ok { + inboundIdsFromJSON = append(inboundIdsFromJSON, int(num)) + } else if num, ok := val.(int); ok { + inboundIdsFromJSON = append(inboundIdsFromJSON, num) + } + } + } else if num, ok := inboundIdsVal.(float64); ok { + // Single number instead of array + inboundIdsFromJSON = append(inboundIdsFromJSON, int(num)) + } else if num, ok := inboundIdsVal.(int); ok { + inboundIdsFromJSON = append(inboundIdsFromJSON, num) + } + } + } + // Restore body for ShouldBind + c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + } + } + + client := &model.ClientEntity{} + err := c.ShouldBind(client) + if err != nil { + jsonMsg(c, "Invalid client data", err) + return + } + + // Set inboundIds from JSON if available + if hasInboundIdsInJSON { + client.InboundIds = inboundIdsFromJSON + } else { + // Try to get from form data + inboundIdsStr := c.PostFormArray("inboundIds") + if len(inboundIdsStr) > 0 { + var inboundIds []int + for _, idStr := range inboundIdsStr { + if idStr != "" { + if id, err := strconv.Atoi(idStr); err == nil && id > 0 { + inboundIds = append(inboundIds, id) + } + } + } + client.InboundIds = inboundIds + } + } + + needRestart, err := a.clientService.AddClient(user.Id, client) + if err != nil { + logger.Errorf("Failed to add client: %v", err) + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + + jsonMsgObj(c, I18nWeb(c, "pages.clients.toasts.clientCreateSuccess"), client, nil) + if needRestart { + // In multi-node mode, this will send config to nodes immediately + // In single mode, this will restart local Xray + if err := a.xrayService.RestartXray(false); err != nil { + logger.Warningf("Failed to restart Xray after client creation: %v", err) + } + } +} + +// updateClient updates an existing client. +func (a *ClientController) updateClient(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + jsonMsg(c, "Invalid client ID", err) + return + } + + user := session.GetLoginUser(c) + + // Extract inboundIds from JSON or form data + var inboundIdsFromJSON []int + var hasInboundIdsInJSON bool + + if c.ContentType() == "application/json" { + // Read raw body to extract inboundIds + bodyBytes, err := c.GetRawData() + if err == nil && len(bodyBytes) > 0 { + // Parse JSON to extract inboundIds + var jsonData map[string]interface{} + if err := json.Unmarshal(bodyBytes, &jsonData); err == nil { + // Check for inboundIds array + if inboundIdsVal, ok := jsonData["inboundIds"]; ok { + hasInboundIdsInJSON = true + if inboundIdsArray, ok := inboundIdsVal.([]interface{}); ok { + for _, val := range inboundIdsArray { + if num, ok := val.(float64); ok { + inboundIdsFromJSON = append(inboundIdsFromJSON, int(num)) + } else if num, ok := val.(int); ok { + inboundIdsFromJSON = append(inboundIdsFromJSON, num) + } + } + } else if num, ok := inboundIdsVal.(float64); ok { + // Single number instead of array + inboundIdsFromJSON = append(inboundIdsFromJSON, int(num)) + } else if num, ok := inboundIdsVal.(int); ok { + inboundIdsFromJSON = append(inboundIdsFromJSON, num) + } + } + } + // Restore body for ShouldBind + c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + } + } + + client := &model.ClientEntity{} + err = c.ShouldBind(client) + if err != nil { + jsonMsg(c, "Invalid client data", err) + return + } + + // Set inboundIds from JSON if available + if hasInboundIdsInJSON { + client.InboundIds = inboundIdsFromJSON + logger.Debugf("UpdateClient: extracted inboundIds from JSON: %v", inboundIdsFromJSON) + } else { + // Try to get from form data + inboundIdsStr := c.PostFormArray("inboundIds") + if len(inboundIdsStr) > 0 { + var inboundIds []int + for _, idStr := range inboundIdsStr { + if idStr != "" { + if id, err := strconv.Atoi(idStr); err == nil && id > 0 { + inboundIds = append(inboundIds, id) + } + } + } + client.InboundIds = inboundIds + logger.Debugf("UpdateClient: extracted inboundIds from form: %v", inboundIds) + } else { + logger.Debugf("UpdateClient: inboundIds not provided, keeping existing assignments") + } + } + + client.Id = id + logger.Debugf("UpdateClient: client.InboundIds = %v", client.InboundIds) + needRestart, err := a.clientService.UpdateClient(user.Id, client) + if err != nil { + logger.Errorf("Failed to update client: %v", err) + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + + jsonMsgObj(c, I18nWeb(c, "pages.clients.toasts.clientUpdateSuccess"), client, nil) + if needRestart { + // In multi-node mode, this will send config to nodes immediately + // In single mode, this will restart local Xray + if err := a.xrayService.RestartXray(false); err != nil { + logger.Warningf("Failed to restart Xray after client update: %v", err) + } + } +} + +// deleteClient deletes a client by ID. +func (a *ClientController) deleteClient(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + jsonMsg(c, "Invalid client ID", err) + return + } + + user := session.GetLoginUser(c) + needRestart, err := a.clientService.DeleteClient(user.Id, id) + if err != nil { + logger.Errorf("Failed to delete client: %v", err) + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + + jsonMsg(c, I18nWeb(c, "pages.clients.toasts.clientDeleteSuccess"), nil) + if needRestart { + // In multi-node mode, this will send config to nodes immediately + // In single mode, this will restart local Xray + if err := a.xrayService.RestartXray(false); err != nil { + logger.Warningf("Failed to restart Xray after client deletion: %v", err) + } + } +} diff --git a/web/controller/client_hwid.go b/web/controller/client_hwid.go new file mode 100644 index 00000000..29ac79b3 --- /dev/null +++ b/web/controller/client_hwid.go @@ -0,0 +1,224 @@ +// Package controller provides HTTP handlers for client HWID management. +package controller + +import ( + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "github.com/mhsanaei/3x-ui/v2/web/service" +) + +// ClientHWIDController handles HTTP requests for client HWID management. +type ClientHWIDController struct { + clientHWIDService *service.ClientHWIDService + clientService *service.ClientService +} + +// NewClientHWIDController creates a new ClientHWIDController. +func NewClientHWIDController(g *gin.RouterGroup) *ClientHWIDController { + a := &ClientHWIDController{ + clientHWIDService: &service.ClientHWIDService{}, + clientService: &service.ClientService{}, + } + a.initRouter(g) + return a +} + +// initRouter sets up routes for client HWID management. +func (a *ClientHWIDController) initRouter(g *gin.RouterGroup) { + g = g.Group("/hwid") + { + g.GET("/list/:clientId", a.getHWIDs) + g.POST("/add", a.addHWID) + g.POST("/del/:id", a.removeHWID) // Changed to /del/:id to match API style + g.POST("/deactivate/:id", a.deactivateHWID) + g.POST("/check", a.checkHWID) + g.POST("/register", a.registerHWID) + } +} + +// getHWIDs retrieves all HWIDs for a specific client. +func (a *ClientHWIDController) getHWIDs(c *gin.Context) { + clientIdStr := c.Param("clientId") + clientId, err := strconv.Atoi(clientIdStr) + if err != nil { + jsonMsg(c, "Invalid client ID", nil) + return + } + + hwids, err := a.clientHWIDService.GetHWIDsForClient(clientId) + if err != nil { + jsonMsg(c, "Failed to get HWIDs", err) + return + } + + jsonObj(c, hwids, nil) +} + +// addHWID adds a new HWID for a client (manual addition by admin). +func (a *ClientHWIDController) addHWID(c *gin.Context) { + var req struct { + ClientId int `json:"clientId" form:"clientId" binding:"required"` + HWID string `json:"hwid" form:"hwid" binding:"required"` + DeviceOS string `json:"deviceOs" form:"deviceOs"` + DeviceModel string `json:"deviceModel" form:"deviceModel"` + OSVersion string `json:"osVersion" form:"osVersion"` + IPAddress string `json:"ipAddress" form:"ipAddress"` + UserAgent string `json:"userAgent" form:"userAgent"` + } + + if err := c.ShouldBind(&req); err != nil { + jsonMsg(c, "Invalid request", err) + return + } + + hwid, err := a.clientHWIDService.AddHWIDForClient(req.ClientId, req.HWID, req.DeviceOS, req.DeviceModel, req.OSVersion, req.IPAddress, req.UserAgent) + if err != nil { + jsonMsg(c, "Failed to add HWID", err) + return + } + + jsonObj(c, hwid, nil) +} + +// removeHWID removes a HWID from a client. +func (a *ClientHWIDController) removeHWID(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil { + jsonMsg(c, "Invalid HWID ID", nil) + return + } + + err = a.clientHWIDService.RemoveHWID(id) + if err != nil { + jsonMsg(c, "Failed to remove HWID", err) + return + } + + jsonMsg(c, "HWID removed successfully", nil) +} + +// deactivateHWID deactivates a HWID (marks as inactive). +func (a *ClientHWIDController) deactivateHWID(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil { + jsonMsg(c, "Invalid HWID ID", nil) + return + } + + err = a.clientHWIDService.DeactivateHWID(id) + if err != nil { + jsonMsg(c, "Failed to deactivate HWID", err) + return + } + + jsonMsg(c, "HWID deactivated successfully", nil) +} + +// checkHWID checks if a HWID is allowed for a client. +func (a *ClientHWIDController) checkHWID(c *gin.Context) { + var req struct { + ClientId int `json:"clientId" form:"clientId" binding:"required"` + HWID string `json:"hwid" form:"hwid" binding:"required"` + } + + if err := c.ShouldBind(&req); err != nil { + jsonMsg(c, "Invalid request", err) + return + } + + allowed, err := a.clientHWIDService.CheckHWIDAllowed(req.ClientId, req.HWID) + if err != nil { + jsonMsg(c, "Failed to check HWID", err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "obj": gin.H{ + "allowed": allowed, + }, + }) +} + +// registerHWID registers a HWID for a client (called by client applications). +// This endpoint reads HWID and device metadata from HTTP headers: +// - x-hwid (required): Hardware ID +// - 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 (a *ClientHWIDController) registerHWID(c *gin.Context) { + var req struct { + Email string `json:"email" form:"email" binding:"required"` + } + + if err := c.ShouldBind(&req); err != nil { + jsonMsg(c, "Invalid request", err) + return + } + + // Read HWID from headers (primary method) + hwid := c.GetHeader("x-hwid") + if hwid == "" { + // Try alternative header name (case-insensitive) + hwid = c.GetHeader("X-HWID") + } + if hwid == "" { + jsonMsg(c, "HWID is required (x-hwid header missing)", nil) + return + } + + // Read device metadata from headers + deviceOS := c.GetHeader("x-device-os") + if deviceOS == "" { + deviceOS = c.GetHeader("X-Device-OS") + } + deviceModel := c.GetHeader("x-device-model") + if deviceModel == "" { + deviceModel = c.GetHeader("X-Device-Model") + } + osVersion := c.GetHeader("x-ver-os") + if osVersion == "" { + osVersion = c.GetHeader("X-Ver-OS") + } + userAgent := c.GetHeader("User-Agent") + ipAddress := c.ClientIP() + + // Get client by email + client, err := a.clientService.GetClientByEmail(1, req.Email) // TODO: Get userId from session + if err != nil { + jsonMsg(c, "Client not found", err) + return + } + + // Register HWID using RegisterHWIDFromHeaders + hwidRecord, err := a.clientHWIDService.RegisterHWIDFromHeaders(client.Id, hwid, deviceOS, deviceModel, osVersion, ipAddress, userAgent) + if err != nil { + // Check if error is HWID limit exceeded + if strings.Contains(err.Error(), "HWID limit exceeded") { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "msg": err.Error(), + }) + return + } + jsonMsg(c, "Failed to register HWID", err) + return + } + + if hwidRecord == nil { + // HWID tracking disabled (hwidMode = "off") + c.JSON(http.StatusOK, gin.H{ + "success": true, + "msg": "HWID tracking is disabled", + }) + return + } + + jsonObj(c, hwidRecord, nil) +} diff --git a/web/controller/host.go b/web/controller/host.go new file mode 100644 index 00000000..d31a2303 --- /dev/null +++ b/web/controller/host.go @@ -0,0 +1,253 @@ +// Package controller provides HTTP handlers for host management in multi-node mode. +package controller + +import ( + "bytes" + "encoding/json" + "io" + "strconv" + + "github.com/mhsanaei/3x-ui/v2/database/model" + "github.com/mhsanaei/3x-ui/v2/logger" + "github.com/mhsanaei/3x-ui/v2/web/service" + "github.com/mhsanaei/3x-ui/v2/web/session" + + "github.com/gin-gonic/gin" +) + +// HostController handles HTTP requests related to host management. +type HostController struct { + hostService service.HostService +} + +// NewHostController creates a new HostController and sets up its routes. +func NewHostController(g *gin.RouterGroup) *HostController { + a := &HostController{ + hostService: service.HostService{}, + } + a.initRouter(g) + return a +} + +// initRouter initializes the routes for host-related operations. +func (a *HostController) initRouter(g *gin.RouterGroup) { + g.GET("/list", a.getHosts) + g.GET("/get/:id", a.getHost) + g.POST("/add", a.addHost) + g.POST("/update/:id", a.updateHost) + g.POST("/del/:id", a.deleteHost) +} + +// getHosts retrieves the list of all hosts for the current user. +func (a *HostController) getHosts(c *gin.Context) { + user := session.GetLoginUser(c) + hosts, err := a.hostService.GetHosts(user.Id) + if err != nil { + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + jsonObj(c, hosts, nil) +} + +// getHost retrieves a specific host by its ID. +func (a *HostController) getHost(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + jsonMsg(c, "Invalid host ID", err) + return + } + user := session.GetLoginUser(c) + host, err := a.hostService.GetHost(id) + if err != nil { + jsonMsg(c, "Failed to get host", err) + return + } + if host.UserId != user.Id { + jsonMsg(c, "Host not found or access denied", nil) + return + } + jsonObj(c, host, nil) +} + +// addHost creates a new host. +func (a *HostController) addHost(c *gin.Context) { + user := session.GetLoginUser(c) + + // Extract inboundIds from JSON or form data + var inboundIdsFromJSON []int + var hasInboundIdsInJSON bool + + if c.ContentType() == "application/json" { + // Read raw body to extract inboundIds + bodyBytes, err := c.GetRawData() + if err == nil && len(bodyBytes) > 0 { + // Parse JSON to extract inboundIds + var jsonData map[string]interface{} + if err := json.Unmarshal(bodyBytes, &jsonData); err == nil { + // Check for inboundIds array + if inboundIdsVal, ok := jsonData["inboundIds"]; ok { + hasInboundIdsInJSON = true + if inboundIdsArray, ok := inboundIdsVal.([]interface{}); ok { + for _, val := range inboundIdsArray { + if num, ok := val.(float64); ok { + inboundIdsFromJSON = append(inboundIdsFromJSON, int(num)) + } else if num, ok := val.(int); ok { + inboundIdsFromJSON = append(inboundIdsFromJSON, num) + } + } + } else if num, ok := inboundIdsVal.(float64); ok { + // Single number instead of array + inboundIdsFromJSON = append(inboundIdsFromJSON, int(num)) + } else if num, ok := inboundIdsVal.(int); ok { + inboundIdsFromJSON = append(inboundIdsFromJSON, num) + } + } + } + // Restore body for ShouldBind + c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + } + } + + host := &model.Host{} + err := c.ShouldBind(host) + if err != nil { + jsonMsg(c, "Invalid host data", err) + return + } + + // Set inboundIds from JSON if available + if hasInboundIdsInJSON { + host.InboundIds = inboundIdsFromJSON + logger.Debugf("AddHost: extracted inboundIds from JSON: %v", inboundIdsFromJSON) + } else { + // Try to get from form data + inboundIdsStr := c.PostFormArray("inboundIds") + if len(inboundIdsStr) > 0 { + var inboundIds []int + for _, idStr := range inboundIdsStr { + if idStr != "" { + if id, err := strconv.Atoi(idStr); err == nil && id > 0 { + inboundIds = append(inboundIds, id) + } + } + } + host.InboundIds = inboundIds + logger.Debugf("AddHost: extracted inboundIds from form: %v", inboundIds) + } + } + + logger.Debugf("AddHost: host.InboundIds before service call: %v", host.InboundIds) + err = a.hostService.AddHost(user.Id, host) + if err != nil { + logger.Errorf("Failed to add host: %v", err) + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + + jsonMsgObj(c, I18nWeb(c, "pages.hosts.toasts.hostCreateSuccess"), host, nil) +} + +// updateHost updates an existing host. +func (a *HostController) updateHost(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + jsonMsg(c, "Invalid host ID", err) + return + } + + user := session.GetLoginUser(c) + + // Extract inboundIds from JSON or form data + var inboundIdsFromJSON []int + var hasInboundIdsInJSON bool + + if c.ContentType() == "application/json" { + // Read raw body to extract inboundIds + bodyBytes, err := c.GetRawData() + if err == nil && len(bodyBytes) > 0 { + // Parse JSON to extract inboundIds + var jsonData map[string]interface{} + if err := json.Unmarshal(bodyBytes, &jsonData); err == nil { + // Check for inboundIds array + if inboundIdsVal, ok := jsonData["inboundIds"]; ok { + hasInboundIdsInJSON = true + if inboundIdsArray, ok := inboundIdsVal.([]interface{}); ok { + for _, val := range inboundIdsArray { + if num, ok := val.(float64); ok { + inboundIdsFromJSON = append(inboundIdsFromJSON, int(num)) + } else if num, ok := val.(int); ok { + inboundIdsFromJSON = append(inboundIdsFromJSON, num) + } + } + } else if num, ok := inboundIdsVal.(float64); ok { + // Single number instead of array + inboundIdsFromJSON = append(inboundIdsFromJSON, int(num)) + } else if num, ok := inboundIdsVal.(int); ok { + inboundIdsFromJSON = append(inboundIdsFromJSON, num) + } + } + } + // Restore body for ShouldBind + c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + } + } + + host := &model.Host{} + err = c.ShouldBind(host) + if err != nil { + jsonMsg(c, "Invalid host data", err) + return + } + + // Set inboundIds from JSON if available + if hasInboundIdsInJSON { + host.InboundIds = inboundIdsFromJSON + logger.Debugf("UpdateHost: extracted inboundIds from JSON: %v", inboundIdsFromJSON) + } else { + // Try to get from form data + inboundIdsStr := c.PostFormArray("inboundIds") + if len(inboundIdsStr) > 0 { + var inboundIds []int + for _, idStr := range inboundIdsStr { + if idStr != "" { + if id, err := strconv.Atoi(idStr); err == nil && id > 0 { + inboundIds = append(inboundIds, id) + } + } + } + host.InboundIds = inboundIds + logger.Debugf("UpdateHost: extracted inboundIds from form: %v", inboundIds) + } else { + logger.Debugf("UpdateHost: inboundIds not provided, keeping existing assignments") + } + } + + host.Id = id + err = a.hostService.UpdateHost(user.Id, host) + if err != nil { + logger.Errorf("Failed to update host: %v", err) + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + + jsonMsgObj(c, I18nWeb(c, "pages.hosts.toasts.hostUpdateSuccess"), host, nil) +} + +// deleteHost deletes a host by ID. +func (a *HostController) deleteHost(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + jsonMsg(c, "Invalid host ID", err) + return + } + + user := session.GetLoginUser(c) + err = a.hostService.DeleteHost(user.Id, id) + if err != nil { + logger.Errorf("Failed to delete host: %v", err) + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + + jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.hostDeleteSuccess"), nil) +} diff --git a/web/controller/xui.go b/web/controller/xui.go index f11a0422..137687eb 100644 --- a/web/controller/xui.go +++ b/web/controller/xui.go @@ -30,10 +30,17 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) { g.GET("/settings", a.settings) g.GET("/xray", a.xraySettings) g.GET("/nodes", a.nodes) + g.GET("/clients", a.clients) + g.GET("/hosts", a.hosts) a.settingController = NewSettingController(g) a.xraySettingController = NewXraySettingController(g) a.nodeController = NewNodeController(g.Group("/node")) + + // Register client and host controllers directly under /panel (not /panel/api) + NewClientController(g.Group("/client")) + NewHostController(g.Group("/host")) + NewClientHWIDController(g.Group("/client")) // Register HWID controller under /panel/client/hwid } // index renders the main panel index page. @@ -60,3 +67,13 @@ func (a *XUIController) xraySettings(c *gin.Context) { func (a *XUIController) nodes(c *gin.Context) { html(c, "nodes.html", "pages.nodes.title", nil) } + +// clients renders the clients management page. +func (a *XUIController) clients(c *gin.Context) { + html(c, "clients.html", "pages.clients.title", nil) +} + +// hosts renders the hosts management page (multi-node mode). +func (a *XUIController) hosts(c *gin.Context) { + html(c, "hosts.html", "pages.hosts.title", nil) +} diff --git a/web/entity/entity.go b/web/entity/entity.go index 030da972..31eb3aeb 100644 --- a/web/entity/entity.go +++ b/web/entity/entity.go @@ -101,6 +101,12 @@ type AllSetting struct { // Multi-node mode setting MultiNodeMode bool `json:"multiNodeMode" form:"multiNodeMode"` // Enable multi-node architecture mode + + // HWID tracking mode + // "off" = HWID tracking disabled + // "client_header" = HWID provided by client via x-hwid header (default, recommended) + // "legacy_fingerprint" = deprecated fingerprint-based HWID generation (deprecated, for backward compatibility only) + HwidMode string `json:"hwidMode" form:"hwidMode"` // HWID tracking mode // JSON subscription routing rules } @@ -171,5 +177,15 @@ func (s *AllSetting) CheckValid() error { return common.NewError("time location not exist:", s.TimeLocation) } + // Validate HWID mode + validHwidModes := map[string]bool{ + "off": true, + "client_header": true, + "legacy_fingerprint": true, + } + if s.HwidMode != "" && !validHwidModes[s.HwidMode] { + return common.NewErrorf("invalid hwidMode: %s (must be one of: off, client_header, legacy_fingerprint)", s.HwidMode) + } + return nil } diff --git a/web/html/clients.html b/web/html/clients.html new file mode 100644 index 00000000..19e033f7 --- /dev/null +++ b/web/html/clients.html @@ -0,0 +1,730 @@ +{{ template "page/head_start" .}} +{{ template "page/head_end" .}} + +{{ template "page/body_start" .}} + + + + + + + + + {{ i18n "pages.clients.title" }} + + + {{ i18n "pages.clients.addClient" }} + {{ i18n "refresh" }} + + + + + {{ i18n "pages.inbounds.autoRefresh" }} + + + + + {{ i18n "pages.inbounds.autoRefreshInterval" }} + + [[ key ]]s + + + + + + + + + + + e.preventDefault()" type="more" + :style="{ fontSize: '20px', textDecoration: 'solid' }"> + clickAction(a, client)" + :theme="themeSwitcher.currentTheme"> + + + {{ i18n "qrCode" }} + + + + {{ i18n "edit" }} + + + + {{ i18n "delete" }} + + + + + + [[ client.email || '-' ]] + + + + + [[ inbound.remark || `Port ${inbound.port}` ]] (ID: [[ inbound.id ]]) + + + {{ i18n "none" }} + + + + + + {{ i18n "online" }} + {{ i18n "offline" }} + + + + + + + ↑[[ SizeFormatter.sizeFormat(client.up || 0) ]] + ↓[[ SizeFormatter.sizeFormat(client.down || 0) ]] + + + {{ i18n "remained" }} + [[ SizeFormatter.sizeFormat(getClientTotal(client) - (client.up || 0) - (client.down || 0)) ]] + + + + + [[ SizeFormatter.sizeFormat((client.up || 0) + (client.down || 0)) ]] / + + [[ SizeFormatter.sizeFormat(getClientTotal(client)) ]] + + + + + + + + + + + + + [[ IntlUtil.formatDate(client.expiryTime) ]] + + + [[ IntlUtil.formatRelativeTime(client.expiryTime) ]] + + + + + + + + + + + + + + + + + + + + + + +{{template "page/body_scripts" .}} + + + + +{{template "component/aSidebar" .}} +{{template "component/aThemeSwitch" .}} +{{template "modals/qrcodeModal"}} +{{template "modals/clientEntityModal"}} + +{{ template "page/body_end" .}} diff --git a/web/html/component/aSidebar.html b/web/html/component/aSidebar.html index 8f0b1ee5..1dbc0e3b 100644 --- a/web/html/component/aSidebar.html +++ b/web/html/component/aSidebar.html @@ -76,6 +76,11 @@ icon: 'user', title: '{{ i18n "menu.inbounds"}}' }, + { + key: '{{ .base_path }}panel/clients', + icon: 'team', + title: '{{ i18n "menu.clients"}}' + }, { key: '{{ .base_path }}panel/settings', icon: 'setting', @@ -88,13 +93,18 @@ } ]; - // Add Nodes menu item if multi-node mode is enabled + // Add Nodes and Hosts menu items if multi-node mode is enabled if (this.multiNodeMode) { - this.tabs.splice(3, 0, { + this.tabs.splice(4, 0, { key: '{{ .base_path }}panel/nodes', icon: 'cluster', title: '{{ i18n "menu.nodes"}}' }); + this.tabs.splice(5, 0, { + key: '{{ .base_path }}panel/hosts', + icon: 'cloud-server', + title: '{{ i18n "menu.hosts"}}' + }); } this.tabs.push({ diff --git a/web/html/form/protocol/shadowsocks.html b/web/html/form/protocol/shadowsocks.html index 06e12075..2f3ec787 100644 --- a/web/html/form/protocol/shadowsocks.html +++ b/web/html/form/protocol/shadowsocks.html @@ -1,11 +1,6 @@ {{define "form/shadowsocks"}} - - - {{template "form/client"}} - - - + diff --git a/web/html/form/protocol/trojan.html b/web/html/form/protocol/trojan.html index fc327721..14e560d1 100644 --- a/web/html/form/protocol/trojan.html +++ b/web/html/form/protocol/trojan.html @@ -1,10 +1,5 @@ {{define "form/trojan"}} - - - {{template "form/client"}} - - - + diff --git a/web/html/form/protocol/vless.html b/web/html/form/protocol/vless.html index fc9c3852..0e68680f 100644 --- a/web/html/form/protocol/vless.html +++ b/web/html/form/protocol/vless.html @@ -1,10 +1,5 @@ {{define "form/vless"}} - - - {{template "form/client"}} - - - + diff --git a/web/html/form/protocol/vmess.html b/web/html/form/protocol/vmess.html index 3c5200ac..150b632f 100644 --- a/web/html/form/protocol/vmess.html +++ b/web/html/form/protocol/vmess.html @@ -1,10 +1,5 @@ {{define "form/vmess"}} - - - {{template "form/client"}} - - - + diff --git a/web/html/hosts.html b/web/html/hosts.html new file mode 100644 index 00000000..ca995169 --- /dev/null +++ b/web/html/hosts.html @@ -0,0 +1,395 @@ +{{ template "page/head_start" .}} +{{ template "page/head_end" .}} + +{{ template "page/body_start" .}} + + + + + + + + {{ i18n "pages.hosts.title" }} + + + {{ i18n "pages.hosts.addNewHost" }} + + + + {{ i18n "refresh" }} + + + + + + e.preventDefault()" type="more" + :style="{ fontSize: '20px', textDecoration: 'solid' }"> + clickAction(a, host)" + :theme="themeSwitcher.currentTheme"> + + + {{ i18n "edit" }} + + + + {{ i18n "delete" }} + + + + + + + + + + + [[ inbound.remark || `Port ${inbound.port}` ]] (ID: [[ inbound.id ]]) + + + {{ i18n "none" }} + + + + + + + + + + + + + + + + + + + +{{template "page/body_scripts" .}} +{{template "component/aSidebar" .}} +{{template "component/aThemeSwitch" .}} +{{template "modals/hostModal"}} + +{{ template "page/body_end" .}} diff --git a/web/html/inbounds.html b/web/html/inbounds.html index a6143896..03450eee 100644 --- a/web/html/inbounds.html +++ b/web/html/inbounds.html @@ -204,14 +204,6 @@ {{ i18n "qrCode" }} - - - {{ i18n "pages.client.add"}} - - - - {{ i18n "pages.client.bulk"}} - {{ i18n "pages.inbounds.resetInboundClientTraffics"}} @@ -587,13 +579,6 @@ - - - {{template "component/aClientTable"}} - - @@ -1018,12 +1003,6 @@ case "edit": this.openEditInbound(dbInbound.id); break; - case "addClient": - this.openAddClient(dbInbound.id) - break; - case "addBulkClient": - this.openAddBulkClient(dbInbound.id) - break; case "export": this.inboundLinks(dbInbound.id); break; diff --git a/web/html/modals/client_entity_modal.html b/web/html/modals/client_entity_modal.html new file mode 100644 index 00000000..f8b76362 --- /dev/null +++ b/web/html/modals/client_entity_modal.html @@ -0,0 +1,307 @@ +{{define "modals/clientEntityModal"}} + + + + + + + + + + + + + + + + + + {{ i18n "none" }} + [[ key ]] + + + + + {{ i18n "none" }} + [[ key ]] + + + + + + + + + + + + + + + + + + + + + + + + + + [[ inbound.remark || `Port ${inbound.port}` ]] (ID: [[ inbound.id ]]) + + + + {{ i18n "hwidSettings" }} + + + + + + + + + + 0 = {{ i18n "unlimited" }} + + + + + + + + + + [[ record.deviceModel || record.deviceName || record.deviceOs || 'Unknown Device' ]] + HWID: [[ record.hwid ]] + + + + {{ i18n "active" }} + {{ i18n "inactive" }} + + + [[ clientEntityModal.formatTimestamp(record.firstSeenAt || record.firstSeen) ]] + + + [[ clientEntityModal.formatTimestamp(record.lastSeenAt || record.lastSeen) ]] + + + {{ i18n "delete" }} + + + + + + + + + +{{end}} diff --git a/web/html/modals/client_modal.html b/web/html/modals/client_modal.html index 8b57b8b2..9e4c2f92 100644 --- a/web/html/modals/client_modal.html +++ b/web/html/modals/client_modal.html @@ -1,4 +1,9 @@ {{define "modals/clientsModal"}} + +{{end}} diff --git a/web/html/modals/inbound_modal.html b/web/html/modals/inbound_modal.html index 5833aa7c..dad31a41 100644 --- a/web/html/modals/inbound_modal.html +++ b/web/html/modals/inbound_modal.html @@ -132,9 +132,6 @@ get isEdit() { return inModal.isEdit; }, - get client() { - return inModal.inbound && inModal.inbound.clients && inModal.inbound.clients.length > 0 ? inModal.inbound.clients[0] : null; - }, get datepicker() { return app.datepicker; }, @@ -144,12 +141,6 @@ get availableNodes() { return app && app.availableNodes || []; }, - get delayedExpireDays() { - return this.client && this.client.expiryTime < 0 ? this.client.expiryTime / -86400000 : 0; - }, - set delayedExpireDays(days) { - this.client.expiryTime = -86400000 * days; - }, get externalProxy() { return this.inbound.stream.externalProxy.length > 0; }, diff --git a/web/html/modals/qrcode_modal.html b/web/html/modals/qrcode_modal.html index 68f672eb..2da0e8fc 100644 --- a/web/html/modals/qrcode_modal.html +++ b/web/html/modals/qrcode_modal.html @@ -21,7 +21,7 @@ - + {{ i18n "pages.settings.subSettings"}} @@ -30,7 +30,7 @@ - + {{ i18n "pages.settings.subSettings"}} Json @@ -110,7 +110,8 @@ this.dbInbound = dbInbound; this.inbound = dbInbound.toInbound(); this.client = client; - this.subId = ''; + // Set subId from client if available + this.subId = (client && client.subId) ? client.subId : ''; this.qrcodes = []; // Reset the status fetched flag when showing the modal if (qrModalApp) qrModalApp.statusFetched = false; @@ -244,9 +245,15 @@ }); }, genSubLink(subID) { + if (!app || !app.subSettings || !app.subSettings.subURI) { + return ''; + } return app.subSettings.subURI + subID; }, genSubJsonLink(subID) { + if (!app || !app.subSettings || !app.subSettings.subJsonURI) { + return ''; + } return app.subSettings.subJsonURI + subID; }, revertOverflow() { @@ -274,9 +281,15 @@ } if (qrModal.client && qrModal.client.subId) { qrModal.subId = qrModal.client.subId; - this.setQrCode("qrCode-sub", this.genSubLink(qrModal.subId)); - if (app.subSettings.subJsonEnable) { - this.setQrCode("qrCode-subJson", this.genSubJsonLink(qrModal.subId)); + const subLink = this.genSubLink(qrModal.subId); + if (subLink) { + this.setQrCode("qrCode-sub", subLink); + } + if (app && app.subSettings && app.subSettings.subJsonEnable) { + const subJsonLink = this.genSubJsonLink(qrModal.subId); + if (subJsonLink) { + this.setQrCode("qrCode-subJson", subJsonLink); + } } } qrModal.qrcodes.forEach((element, index) => { diff --git a/web/job/check_client_hwid_job.go b/web/job/check_client_hwid_job.go new file mode 100644 index 00000000..959f208c --- /dev/null +++ b/web/job/check_client_hwid_job.go @@ -0,0 +1,155 @@ +// Package job provides scheduled tasks for monitoring client HWIDs from access logs. +// NOTE: In client_header mode, this job does NOT generate HWIDs from logs. +// HWID registration happens explicitly via RegisterHWIDFromHeaders when subscription is requested. +package job + +import ( + "time" + + "github.com/mhsanaei/3x-ui/v2/database" + "github.com/mhsanaei/3x-ui/v2/database/model" + "github.com/mhsanaei/3x-ui/v2/logger" + "github.com/mhsanaei/3x-ui/v2/web/service" + "github.com/mhsanaei/3x-ui/v2/xray" +) + +// CheckClientHWIDJob monitors client HWIDs from access logs and manages HWID tracking. +type CheckClientHWIDJob struct { + lastClear int64 +} + +var hwidJob *CheckClientHWIDJob + +// NewCheckClientHWIDJob creates a new client HWID monitoring job instance. +func NewCheckClientHWIDJob() *CheckClientHWIDJob { + if hwidJob == nil { + hwidJob = new(CheckClientHWIDJob) + } + return hwidJob +} + +// Run executes the HWID monitoring job. +func (j *CheckClientHWIDJob) Run() { + // Check if multi-node mode is enabled + settingService := service.SettingService{} + multiMode, err := settingService.GetMultiNodeMode() + if err == nil && multiMode { + // In multi-node mode, HWID checking is handled by nodes + return + } + + if j.lastClear == 0 { + j.lastClear = time.Now().Unix() + } + + hwidTrackingActive := j.hasHWIDTracking() + if !hwidTrackingActive { + return + } + + isAccessLogAvailable := j.checkAccessLogAvailable() + if !isAccessLogAvailable { + return + } + + // Process access log to track HWIDs + j.processLogFile() + + // Clear access log periodically (every hour) + if time.Now().Unix()-j.lastClear > 3600 { + j.clearAccessLog() + } +} + +// hasHWIDTracking checks if HWID tracking is enabled globally and for any client. +func (j *CheckClientHWIDJob) hasHWIDTracking() bool { + // Check global HWID mode setting + settingService := service.SettingService{} + hwidMode, err := settingService.GetHwidMode() + if err != nil { + logger.Warningf("Failed to get hwidMode setting: %v", err) + return false + } + + // If HWID tracking is disabled globally, skip + if hwidMode == "off" { + return false + } + + // Check if any client has HWID tracking enabled + db := database.GetDB() + var clients []*model.ClientEntity + + err = db.Where("hwid_enabled = ?", true).Find(&clients).Error + if err != nil { + return false + } + + return len(clients) > 0 +} + +// checkAccessLogAvailable checks if access log is available. +func (j *CheckClientHWIDJob) checkAccessLogAvailable() bool { + accessLogPath, err := xray.GetAccessLogPath() + if err != nil { + return false + } + + if accessLogPath == "none" || accessLogPath == "" { + return false + } + + return true +} + +// processLogFile processes the access log file to update last_seen_at and IP for existing HWIDs. +// NOTE: This job does NOT generate or create new HWID records. +// HWID registration must be done explicitly via RegisterHWIDFromHeaders when x-hwid header is provided. +// This job only updates existing HWID records with connection information from access logs. +func (j *CheckClientHWIDJob) processLogFile() { + // Check HWID mode - only run in legacy_fingerprint mode + settingService := service.SettingService{} + hwidMode, err := settingService.GetHwidMode() + if err != nil { + logger.Warningf("Failed to get hwidMode setting: %v", err) + return + } + + // In client_header mode, this job should not process logs for HWID generation + // It may still update last_seen_at for existing HWIDs if needed + if hwidMode == "off" { + // HWID tracking disabled - skip processing + return + } + + // In client_header mode, we don't generate HWIDs from logs + // Only update existing HWIDs if we can match them somehow + // For now, skip log processing in client_header mode + // (HWID registration happens via RegisterHWIDFromHeaders when subscription is requested) + if hwidMode == "client_header" { + // In client_header mode, HWID comes from headers, not logs + // This job should not process logs for HWID generation + // TODO: Could potentially update last_seen_at for existing HWIDs if we can match them, + // but without x-hwid header in logs, we can't reliably match + return + } + + // Legacy fingerprint mode (deprecated) + // This mode may use fingerprint-based HWID generation from logs + if hwidMode == "legacy_fingerprint" { + // Legacy mode: may generate HWID from logs (deprecated behavior) + // This is kept for backward compatibility only + logger.Debug("Running in legacy_fingerprint mode (deprecated)") + // TODO: Implement legacy fingerprint logic if needed for backward compatibility + // For now, skip to avoid false positives + return + } +} + +// clearAccessLog clears the access log file (similar to CheckClientIpJob). +func (j *CheckClientHWIDJob) clearAccessLog() { + // This is similar to CheckClientIpJob.clearAccessLog + // We can reuse the same logic or call it from there + // For now, we'll just update the last clear time + j.lastClear = time.Now().Unix() +} diff --git a/web/service/client.go b/web/service/client.go new file mode 100644 index 00000000..13d00d53 --- /dev/null +++ b/web/service/client.go @@ -0,0 +1,602 @@ +// Package service provides Client management service. +package service + +import ( + "strings" + "time" + + "github.com/google/uuid" + "github.com/mhsanaei/3x-ui/v2/database" + "github.com/mhsanaei/3x-ui/v2/database/model" + "github.com/mhsanaei/3x-ui/v2/logger" + "github.com/mhsanaei/3x-ui/v2/util/common" + "github.com/mhsanaei/3x-ui/v2/util/random" + "github.com/mhsanaei/3x-ui/v2/xray" + + "gorm.io/gorm" +) + +// ClientService provides business logic for managing clients. +type ClientService struct{} + +// GetClients retrieves all clients for a specific user. +// Also loads traffic statistics and last online time for each client. +func (s *ClientService) GetClients(userId int) ([]*model.ClientEntity, error) { + db := database.GetDB() + var clients []*model.ClientEntity + err := db.Where("user_id = ?", userId).Find(&clients).Error + if err != nil { + return nil, err + } + + // Load inbound assignments, traffic statistics, and HWIDs for each client + for _, client := range clients { + // Load inbound assignments + inboundIds, err := s.GetInboundIdsForClient(client.Id) + if err == nil { + client.InboundIds = inboundIds + } + + // Load traffic statistics from client_traffics table by email + var clientTraffic xray.ClientTraffic + err = db.Where("email = ?", strings.ToLower(client.Email)).First(&clientTraffic).Error + if err == nil { + // Traffic found - set traffic fields on client entity + client.Up = clientTraffic.Up + client.Down = clientTraffic.Down + client.AllTime = clientTraffic.AllTime + client.LastOnline = clientTraffic.LastOnline + // Note: expiryTime and totalGB are stored in ClientEntity, we don't override them from traffic + // Traffic table may have different values due to legacy data + } else if err != gorm.ErrRecordNotFound { + logger.Warningf("Failed to load traffic for client %s: %v", client.Email, err) + } + // If not found, traffic will be 0 (default values) + + // Load HWIDs for this client + hwidService := ClientHWIDService{} + hwids, err := hwidService.GetHWIDsForClient(client.Id) + if err == nil { + client.HWIDs = hwids + } else { + logger.Warningf("Failed to load HWIDs for client %d: %v", client.Id, err) + } + } + + return clients, nil +} + +// GetClient retrieves a client by ID. +func (s *ClientService) GetClient(id int) (*model.ClientEntity, error) { + db := database.GetDB() + var client model.ClientEntity + err := db.First(&client, id).Error + if err != nil { + return nil, err + } + + // Load inbound assignments + inboundIds, err := s.GetInboundIdsForClient(client.Id) + if err == nil { + client.InboundIds = inboundIds + } + + // Load HWIDs for this client + hwidService := ClientHWIDService{} + hwids, err := hwidService.GetHWIDsForClient(client.Id) + if err == nil { + client.HWIDs = hwids + } + + return &client, nil +} + +// GetClientByEmail retrieves a client by email for a specific user. +func (s *ClientService) GetClientByEmail(userId int, email string) (*model.ClientEntity, error) { + db := database.GetDB() + var client model.ClientEntity + err := db.Where("user_id = ? AND email = ?", userId, strings.ToLower(email)).First(&client).Error + if err != nil { + return nil, err + } + + // Load inbound assignments + inboundIds, err := s.GetInboundIdsForClient(client.Id) + if err == nil { + client.InboundIds = inboundIds + } + + return &client, nil +} + +// GetInboundIdsForClient retrieves all inbound IDs assigned to a client. +func (s *ClientService) GetInboundIdsForClient(clientId int) ([]int, error) { + db := database.GetDB() + var mappings []model.ClientInboundMapping + err := db.Where("client_id = ?", clientId).Find(&mappings).Error + if err != nil { + return nil, err + } + + inboundIds := make([]int, len(mappings)) + for i, mapping := range mappings { + inboundIds[i] = mapping.InboundId + } + + return inboundIds, nil +} + +// AddClient creates a new client. +// Returns whether Xray needs restart and any error. +func (s *ClientService) AddClient(userId int, client *model.ClientEntity) (bool, error) { + // Validate email uniqueness for this user + existing, err := s.GetClientByEmail(userId, client.Email) + if err == nil && existing != nil { + return false, common.NewError("Client with email already exists: ", client.Email) + } + + // Generate UUID if not provided and needed + if client.UUID == "" { + newUUID, err := uuid.NewRandom() + if err != nil { + return false, common.NewError("Failed to generate UUID: ", err.Error()) + } + client.UUID = newUUID.String() + } + + // Generate SubID if not provided + if client.SubID == "" { + client.SubID = random.Seq(16) + } + + // Normalize email to lowercase + client.Email = strings.ToLower(client.Email) + client.UserId = userId + + // Set timestamps + now := time.Now().Unix() + if client.CreatedAt == 0 { + client.CreatedAt = now + } + client.UpdatedAt = now + + db := database.GetDB() + tx := db.Begin() + defer func() { + if err != nil { + tx.Rollback() + } else { + tx.Commit() + } + }() + + err = tx.Create(client).Error + if err != nil { + return false, err + } + + // Create initial ClientTraffic record if it doesn't exist + // This ensures traffic statistics are tracked from the start + var count int64 + tx.Model(&xray.ClientTraffic{}).Where("email = ?", client.Email).Count(&count) + if count == 0 { + // Create traffic record for the first assigned inbound, or use 0 if no inbounds yet + inboundId := 0 + if len(client.InboundIds) > 0 { + inboundId = client.InboundIds[0] + } + clientTraffic := xray.ClientTraffic{ + InboundId: inboundId, + Email: client.Email, + Total: client.TotalGB, + ExpiryTime: client.ExpiryTime, + Enable: client.Enable, + Up: 0, + Down: 0, + Reset: client.Reset, + } + err = tx.Create(&clientTraffic).Error + if err != nil { + logger.Warningf("Failed to create ClientTraffic for client %s: %v", client.Email, err) + // Don't fail the whole operation if traffic record creation fails + } + } + + // Assign to inbounds if provided + if len(client.InboundIds) > 0 { + err = s.AssignClientToInbounds(tx, client.Id, client.InboundIds) + if err != nil { + return false, err + } + } + + // Commit client transaction first to avoid nested transactions + err = tx.Commit().Error + if err != nil { + return false, err + } + + // Now update Settings for all assigned inbounds + // This is done AFTER committing the client transaction to avoid nested transactions and database locks + needRestart := false + if len(client.InboundIds) > 0 { + inboundService := InboundService{} + for _, inboundId := range client.InboundIds { + inbound, err := inboundService.GetInbound(inboundId) + if err != nil { + logger.Warningf("Failed to get inbound %d for settings update: %v", inboundId, err) + continue + } + + // Get all clients for this inbound (from ClientEntity) + clientEntities, err := s.GetClientsForInbound(inboundId) + if err != nil { + logger.Warningf("Failed to get clients for inbound %d: %v", inboundId, err) + continue + } + + // Rebuild Settings from ClientEntity + newSettings, err := inboundService.BuildSettingsFromClientEntities(inbound, clientEntities) + if err != nil { + logger.Warningf("Failed to build settings for inbound %d: %v", inboundId, err) + continue + } + + // Update inbound Settings (this will open its own transaction) + // Use retry logic to handle database lock errors + inbound.Settings = newSettings + _, inboundNeedRestart, err := inboundService.updateInboundWithRetry(inbound) + if err != nil { + logger.Warningf("Failed to update inbound %d settings: %v", inboundId, err) + // Continue with other inbounds + } else if inboundNeedRestart { + needRestart = true + } + } + } + + return needRestart, nil +} + +// UpdateClient updates an existing client. +// Returns whether Xray needs restart and any error. +func (s *ClientService) UpdateClient(userId int, client *model.ClientEntity) (bool, error) { + // Check if client exists and belongs to user + existing, err := s.GetClient(client.Id) + if err != nil { + return false, err + } + if existing.UserId != userId { + return false, common.NewError("Client not found or access denied") + } + + // Check email uniqueness if email changed + if client.Email != "" && strings.ToLower(client.Email) != strings.ToLower(existing.Email) { + existingByEmail, err := s.GetClientByEmail(userId, client.Email) + if err == nil && existingByEmail != nil && existingByEmail.Id != client.Id { + return false, common.NewError("Client with email already exists: ", client.Email) + } + } + + // Normalize email to lowercase if provided + if client.Email != "" { + client.Email = strings.ToLower(client.Email) + } + + // Update timestamp + client.UpdatedAt = time.Now().Unix() + + db := database.GetDB() + tx := db.Begin() + // Track if transaction was committed to avoid double rollback + committed := false + defer func() { + // Only rollback if there was an error and transaction wasn't committed + if err != nil && !committed { + tx.Rollback() + } + }() + + // Update only provided fields + updates := make(map[string]interface{}) + if client.Email != "" { + updates["email"] = client.Email + } + if client.UUID != "" { + updates["uuid"] = client.UUID + } + if client.Security != "" { + updates["security"] = client.Security + } + if client.Password != "" { + updates["password"] = client.Password + } + if client.Flow != "" { + updates["flow"] = client.Flow + } + if client.LimitIP > 0 { + updates["limit_ip"] = client.LimitIP + } + if client.TotalGB > 0 { + updates["total_gb"] = client.TotalGB + } + if client.ExpiryTime != 0 { + updates["expiry_time"] = client.ExpiryTime + } + updates["enable"] = client.Enable + if client.TgID > 0 { + updates["tg_id"] = client.TgID + } + if client.SubID != "" { + updates["sub_id"] = client.SubID + } + if client.Comment != "" { + updates["comment"] = client.Comment + } + if client.Reset > 0 { + updates["reset"] = client.Reset + } + // Update HWID settings - GORM converts field names to snake_case automatically + // HWIDEnabled -> hwid_enabled, MaxHWID -> max_hwid + // But we need to check if columns exist first, or use direct field assignment + updates["hwid_enabled"] = client.HWIDEnabled + updates["max_hwid"] = client.MaxHWID + updates["updated_at"] = client.UpdatedAt + + // First try to update with all fields including HWID + err = tx.Model(&model.ClientEntity{}).Where("id = ? AND user_id = ?", client.Id, userId).Updates(updates).Error + if err != nil { + // If HWID columns don't exist, remove them and try again + if strings.Contains(err.Error(), "no such column: hwid_enabled") || strings.Contains(err.Error(), "no such column: max_hwid") { + delete(updates, "hwid_enabled") + delete(updates, "max_hwid") + err = tx.Model(&model.ClientEntity{}).Where("id = ? AND user_id = ?", client.Id, userId).Updates(updates).Error + } + } + if err != nil { + return false, err + } + + // Get current inbound assignments to determine which inbounds need updating + var currentMappings []model.ClientInboundMapping + tx.Where("client_id = ?", client.Id).Find(¤tMappings) + oldInboundIds := make(map[int]bool) + for _, mapping := range currentMappings { + oldInboundIds[mapping.InboundId] = true + } + + // Track all affected inbounds (old + new) for settings update + affectedInboundIds := make(map[int]bool) + for inboundId := range oldInboundIds { + affectedInboundIds[inboundId] = true + } + + // Update inbound assignments if provided + // Note: InboundIds is a slice, so we need to check if it was explicitly set + // We'll always update if InboundIds is not nil (even if empty array means remove all) + if client.InboundIds != nil { + // Remove existing assignments + err = tx.Where("client_id = ?", client.Id).Delete(&model.ClientInboundMapping{}).Error + if err != nil { + return false, err + } + + // Add new assignments (if any) + if len(client.InboundIds) > 0 { + err = s.AssignClientToInbounds(tx, client.Id, client.InboundIds) + if err != nil { + return false, err + } + // Track new inbound IDs for settings update + for _, inboundId := range client.InboundIds { + affectedInboundIds[inboundId] = true + } + } + } + + // Commit client transaction first to avoid nested transactions + err = tx.Commit().Error + committed = true + if err != nil { + return false, err + } + + // Now update Settings for all affected inbounds (old + new) + // This is needed even if InboundIds wasn't changed, because client data (UUID, password, etc.) might have changed + // We do this AFTER committing the client transaction to avoid nested transactions and database locks + needRestart := false + inboundService := InboundService{} + for inboundId := range affectedInboundIds { + inbound, err := inboundService.GetInbound(inboundId) + if err != nil { + logger.Warningf("Failed to get inbound %d for settings update: %v", inboundId, err) + continue + } + + // Get all clients for this inbound (from ClientEntity) + clientEntities, err := s.GetClientsForInbound(inboundId) + if err != nil { + logger.Warningf("Failed to get clients for inbound %d: %v", inboundId, err) + continue + } + + // Rebuild Settings from ClientEntity + newSettings, err := inboundService.BuildSettingsFromClientEntities(inbound, clientEntities) + if err != nil { + logger.Warningf("Failed to build settings for inbound %d: %v", inboundId, err) + continue + } + + // Update inbound Settings (this will open its own transaction) + // Use retry logic to handle database lock errors + inbound.Settings = newSettings + _, inboundNeedRestart, err := inboundService.updateInboundWithRetry(inbound) + if err != nil { + logger.Warningf("Failed to update inbound %d settings: %v", inboundId, err) + // Continue with other inbounds + } else if inboundNeedRestart { + needRestart = true + } + } + + return needRestart, nil +} + +// DeleteClient deletes a client by ID. +// Returns whether Xray needs restart and any error. +func (s *ClientService) DeleteClient(userId int, id int) (bool, error) { + // Check if client exists and belongs to user + existing, err := s.GetClient(id) + if err != nil { + return false, err + } + if existing.UserId != userId { + return false, common.NewError("Client not found or access denied") + } + + // Get inbound assignments before deleting + var mappings []model.ClientInboundMapping + db := database.GetDB() + err = db.Where("client_id = ?", id).Find(&mappings).Error + if err != nil { + return false, err + } + + affectedInboundIds := make(map[int]bool) + for _, mapping := range mappings { + affectedInboundIds[mapping.InboundId] = true + } + + needRestart := false + + tx := db.Begin() + // Track if transaction was committed to avoid double rollback + committed := false + defer func() { + // Only rollback if there was an error and transaction wasn't committed + if err != nil && !committed { + tx.Rollback() + } + }() + + // Delete inbound mappings + err = tx.Where("client_id = ?", id).Delete(&model.ClientInboundMapping{}).Error + if err != nil { + return false, err + } + + // Delete client + err = tx.Where("id = ? AND user_id = ?", id, userId).Delete(&model.ClientEntity{}).Error + if err != nil { + return false, err + } + + // Commit deletion transaction first to avoid nested transactions + err = tx.Commit().Error + committed = true + if err != nil { + return false, err + } + + // Update Settings for affected inbounds (after deletion) + // We do this AFTER committing the deletion transaction to avoid nested transactions and database locks + inboundService := InboundService{} + for inboundId := range affectedInboundIds { + inbound, err := inboundService.GetInbound(inboundId) + if err != nil { + logger.Warningf("Failed to get inbound %d for settings update: %v", inboundId, err) + continue + } + + // Get all remaining clients for this inbound (from ClientEntity) + clientEntities, err := s.GetClientsForInbound(inboundId) + if err != nil { + logger.Warningf("Failed to get clients for inbound %d: %v", inboundId, err) + continue + } + + // Rebuild Settings from ClientEntity + newSettings, err := inboundService.BuildSettingsFromClientEntities(inbound, clientEntities) + if err != nil { + logger.Warningf("Failed to build settings for inbound %d: %v", inboundId, err) + continue + } + + // Update inbound Settings (this will open its own transaction) + // Use retry logic to handle database lock errors + inbound.Settings = newSettings + _, inboundNeedRestart, err := inboundService.updateInboundWithRetry(inbound) + if err != nil { + logger.Warningf("Failed to update inbound %d settings: %v", inboundId, err) + // Continue with other inbounds + } else if inboundNeedRestart { + needRestart = true + } + } + + return needRestart, nil +} + +// AssignClientToInbounds assigns a client to multiple inbounds. +func (s *ClientService) AssignClientToInbounds(tx *gorm.DB, clientId int, inboundIds []int) error { + for _, inboundId := range inboundIds { + mapping := &model.ClientInboundMapping{ + ClientId: clientId, + InboundId: inboundId, + } + err := tx.Create(mapping).Error + if err != nil { + logger.Warningf("Failed to assign client %d to inbound %d: %v", clientId, inboundId, err) + // Continue with other assignments + } + } + return nil +} + +// GetClientsForInbound retrieves all clients assigned to an inbound. +func (s *ClientService) GetClientsForInbound(inboundId int) ([]*model.ClientEntity, error) { + db := database.GetDB() + var mappings []model.ClientInboundMapping + err := db.Where("inbound_id = ?", inboundId).Find(&mappings).Error + if err != nil { + return nil, err + } + + if len(mappings) == 0 { + return []*model.ClientEntity{}, nil + } + + clientIds := make([]int, len(mappings)) + for i, mapping := range mappings { + clientIds[i] = mapping.ClientId + } + + var clients []*model.ClientEntity + err = db.Where("id IN ?", clientIds).Find(&clients).Error + if err != nil { + return nil, err + } + + return clients, nil +} + +// ConvertClientEntityToClient converts ClientEntity to legacy Client struct for backward compatibility. +func (s *ClientService) ConvertClientEntityToClient(entity *model.ClientEntity) model.Client { + return model.Client{ + ID: entity.UUID, + Security: entity.Security, + Password: entity.Password, + Flow: entity.Flow, + Email: entity.Email, + LimitIP: entity.LimitIP, + TotalGB: entity.TotalGB, + ExpiryTime: entity.ExpiryTime, + Enable: entity.Enable, + TgID: entity.TgID, + SubID: entity.SubID, + Comment: entity.Comment, + Reset: entity.Reset, + CreatedAt: entity.CreatedAt, + UpdatedAt: entity.UpdatedAt, + } +} diff --git a/web/service/client_hwid.go b/web/service/client_hwid.go new file mode 100644 index 00000000..9fcc16e9 --- /dev/null +++ b/web/service/client_hwid.go @@ -0,0 +1,342 @@ +// 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 +} diff --git a/web/service/host.go b/web/service/host.go new file mode 100644 index 00000000..c7ffcde7 --- /dev/null +++ b/web/service/host.go @@ -0,0 +1,254 @@ +// Package service provides Host management service for multi-node mode. +package service + +import ( + "time" + + "github.com/mhsanaei/3x-ui/v2/database" + "github.com/mhsanaei/3x-ui/v2/database/model" + "github.com/mhsanaei/3x-ui/v2/logger" + "github.com/mhsanaei/3x-ui/v2/util/common" + + "gorm.io/gorm" +) + +// HostService provides business logic for managing hosts. +type HostService struct{} + +// GetHosts retrieves all hosts for a specific user. +func (s *HostService) GetHosts(userId int) ([]*model.Host, error) { + db := database.GetDB() + var hosts []*model.Host + err := db.Where("user_id = ?", userId).Find(&hosts).Error + if err != nil { + return nil, err + } + + // Load inbound assignments for each host + for _, host := range hosts { + inboundIds, err := s.GetInboundIdsForHost(host.Id) + if err == nil { + host.InboundIds = inboundIds + } + } + + return hosts, nil +} + +// GetHost retrieves a host by ID. +func (s *HostService) GetHost(id int) (*model.Host, error) { + db := database.GetDB() + var host model.Host + err := db.First(&host, id).Error + if err != nil { + return nil, err + } + + // Load inbound assignments + inboundIds, err := s.GetInboundIdsForHost(host.Id) + if err == nil { + host.InboundIds = inboundIds + } + + return &host, nil +} + +// GetInboundIdsForHost retrieves all inbound IDs assigned to a host. +func (s *HostService) GetInboundIdsForHost(hostId int) ([]int, error) { + db := database.GetDB() + var mappings []model.HostInboundMapping + err := db.Where("host_id = ?", hostId).Find(&mappings).Error + if err != nil { + return nil, err + } + + inboundIds := make([]int, len(mappings)) + for i, mapping := range mappings { + inboundIds[i] = mapping.InboundId + } + + return inboundIds, nil +} + +// GetHostForInbound retrieves the host assigned to an inbound (if any). +// Returns the first enabled host if multiple hosts are assigned. +func (s *HostService) GetHostForInbound(inboundId int) (*model.Host, error) { + db := database.GetDB() + var mapping model.HostInboundMapping + err := db.Where("inbound_id = ?", inboundId).First(&mapping).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil // No host assigned + } + return nil, err + } + + var host model.Host + err = db.Where("id = ? AND enable = ?", mapping.HostId, true).First(&host).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil // Host disabled or not found + } + return nil, err + } + + return &host, nil +} + +// AddHost creates a new host. +func (s *HostService) AddHost(userId int, host *model.Host) error { + host.UserId = userId + + // Set timestamps + now := time.Now().Unix() + if host.CreatedAt == 0 { + host.CreatedAt = now + } + host.UpdatedAt = now + + db := database.GetDB() + tx := db.Begin() + var err error + defer func() { + if err != nil { + tx.Rollback() + } else { + tx.Commit() + } + }() + err = tx.Create(host).Error + if err != nil { + return err + } + + // Assign to inbounds if provided + if len(host.InboundIds) > 0 { + err = s.AssignHostToInbounds(tx, host.Id, host.InboundIds) + if err != nil { + return err + } + } + + return nil +} + +// UpdateHost updates an existing host. +func (s *HostService) UpdateHost(userId int, host *model.Host) error { + // Check if host exists and belongs to user + existing, err := s.GetHost(host.Id) + if err != nil { + return err + } + if existing.UserId != userId { + return common.NewError("Host not found or access denied") + } + + // Update timestamp + host.UpdatedAt = time.Now().Unix() + + db := database.GetDB() + tx := db.Begin() + defer func() { + if err != nil { + tx.Rollback() + } else { + tx.Commit() + } + }() + + // Update only provided fields + updates := make(map[string]interface{}) + if host.Name != "" { + updates["name"] = host.Name + } + if host.Address != "" { + updates["address"] = host.Address + } + if host.Port > 0 { + updates["port"] = host.Port + } + if host.Protocol != "" { + updates["protocol"] = host.Protocol + } + if host.Remark != "" { + updates["remark"] = host.Remark + } + updates["enable"] = host.Enable + updates["updated_at"] = host.UpdatedAt + + err = tx.Model(&model.Host{}).Where("id = ? AND user_id = ?", host.Id, userId).Updates(updates).Error + if err != nil { + return err + } + + // Update inbound assignments if provided + if host.InboundIds != nil { + // Remove existing assignments + err = tx.Where("host_id = ?", host.Id).Delete(&model.HostInboundMapping{}).Error + if err != nil { + return err + } + + // Add new assignments + if len(host.InboundIds) > 0 { + err = s.AssignHostToInbounds(tx, host.Id, host.InboundIds) + if err != nil { + return err + } + } + } + + return nil +} + +// DeleteHost deletes a host by ID. +func (s *HostService) DeleteHost(userId int, id int) error { + // Check if host exists and belongs to user + existing, err := s.GetHost(id) + if err != nil { + return err + } + if existing.UserId != userId { + return common.NewError("Host not found or access denied") + } + + db := database.GetDB() + tx := db.Begin() + defer func() { + if err != nil { + tx.Rollback() + } else { + tx.Commit() + } + }() + + // Delete inbound mappings + err = tx.Where("host_id = ?", id).Delete(&model.HostInboundMapping{}).Error + if err != nil { + return err + } + + // Delete host + err = tx.Where("id = ? AND user_id = ?", id, userId).Delete(&model.Host{}).Error + if err != nil { + return err + } + + return nil +} + +// AssignHostToInbounds assigns a host to multiple inbounds. +func (s *HostService) AssignHostToInbounds(tx *gorm.DB, hostId int, inboundIds []int) error { + for _, inboundId := range inboundIds { + mapping := &model.HostInboundMapping{ + HostId: hostId, + InboundId: inboundId, + } + err := tx.Create(mapping).Error + if err != nil { + logger.Warningf("Failed to assign host %d to inbound %d: %v", hostId, inboundId, err) + // Continue with other assignments + } + } + return nil +} diff --git a/web/service/inbound.go b/web/service/inbound.go index 1561de23..68b0ff16 100644 --- a/web/service/inbound.go +++ b/web/service/inbound.go @@ -8,6 +8,7 @@ import ( "sort" "strconv" "strings" + "sync" "time" "github.com/mhsanaei/3x-ui/v2/database" @@ -26,6 +27,72 @@ type InboundService struct { xrayApi xray.XrayAPI } +// inboundUpdateMutexes provides per-inbound mutexes to prevent concurrent updates +var inboundUpdateMutexes = make(map[int]*sync.Mutex) +var inboundMutexLock sync.Mutex + +// getInboundMutex returns a mutex for a specific inbound ID to prevent concurrent updates +func getInboundMutex(inboundId int) *sync.Mutex { + inboundMutexLock.Lock() + defer inboundMutexLock.Unlock() + + if mutex, exists := inboundUpdateMutexes[inboundId]; exists { + return mutex + } + + mutex := &sync.Mutex{} + inboundUpdateMutexes[inboundId] = mutex + return mutex +} + +// updateInboundWithRetry updates an inbound with retry logic for database lock errors. +// It uses a per-inbound mutex to prevent concurrent updates and retries up to 3 times +// with exponential backoff (50ms, 100ms, 200ms). +func (s *InboundService) updateInboundWithRetry(inbound *model.Inbound) (*model.Inbound, bool, error) { + // Use per-inbound mutex to prevent concurrent updates of the same inbound + mutex := getInboundMutex(inbound.Id) + mutex.Lock() + defer mutex.Unlock() + + maxRetries := 3 + baseDelay := 50 * time.Millisecond + + var result *model.Inbound + var needRestart bool + var err error + + for attempt := 0; attempt < maxRetries; attempt++ { + if attempt > 0 { + // Exponential backoff: 50ms, 100ms, 200ms + delay := baseDelay * time.Duration(1< 0, nil } +// GetClients retrieves clients for an inbound. +// First tries to get clients from ClientEntity (new approach), +// falls back to parsing Settings JSON for backward compatibility. func (s *InboundService) GetClients(inbound *model.Inbound) ([]model.Client, error) { + clientService := ClientService{} + + // Try to get clients from ClientEntity (new approach) + clientEntities, err := clientService.GetClientsForInbound(inbound.Id) + if err == nil && len(clientEntities) > 0 { + // Convert ClientEntity to Client + clients := make([]model.Client, len(clientEntities)) + for i, entity := range clientEntities { + clients[i] = clientService.ConvertClientEntityToClient(entity) + } + return clients, nil + } + + // Fallback: parse from Settings JSON (backward compatibility) settings := map[string][]model.Client{} json.Unmarshal([]byte(inbound.Settings), &settings) if settings == nil { @@ -159,17 +243,84 @@ func (s *InboundService) GetClients(inbound *model.Inbound) ([]model.Client, err return clients, nil } +// BuildSettingsFromClientEntities builds Settings JSON for Xray from ClientEntity. +// This method creates a minimal Settings structure with only fields needed by Xray. +func (s *InboundService) BuildSettingsFromClientEntities(inbound *model.Inbound, clientEntities []*model.ClientEntity) (string, error) { + // Parse existing settings to preserve other fields (like encryption for VLESS) + var settings map[string]any + if inbound.Settings != "" { + json.Unmarshal([]byte(inbound.Settings), &settings) + } + if settings == nil { + settings = make(map[string]any) + } + + // Build clients array for Xray (only minimal fields) + var xrayClients []map[string]any + for _, entity := range clientEntities { + if !entity.Enable { + continue // Skip disabled clients + } + + client := make(map[string]any) + client["email"] = entity.Email + + switch inbound.Protocol { + case model.Trojan: + client["password"] = entity.Password + case model.Shadowsocks: + // For Shadowsocks, we need to get method from settings + if method, ok := settings["method"].(string); ok { + client["method"] = method + } + client["password"] = entity.Password + case model.VMESS, model.VLESS: + client["id"] = entity.UUID + if inbound.Protocol == model.VMESS { + if entity.Security != "" { + client["security"] = entity.Security + } + } + if inbound.Protocol == model.VLESS && entity.Flow != "" { + client["flow"] = entity.Flow + } + } + + xrayClients = append(xrayClients, client) + } + + settings["clients"] = xrayClients + settingsJSON, err := json.MarshalIndent(settings, "", " ") + if err != nil { + return "", err + } + + return string(settingsJSON), nil +} + func (s *InboundService) getAllEmails() ([]string, error) { db := database.GetDB() var emails []string - err := db.Raw(` - SELECT JSON_EXTRACT(client.value, '$.email') - FROM inbounds, - JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client - `).Scan(&emails).Error + + // Get emails from ClientEntity (new approach) + err := db.Model(&model.ClientEntity{}).Pluck("email", &emails).Error if err != nil { return nil, err } + + // Also get emails from Settings JSON (backward compatibility) + var settingsEmails []string + _ = db.Raw(` + SELECT JSON_EXTRACT(client.value, '$.email') + FROM inbounds, + JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client + WHERE JSON_EXTRACT(client.value, '$.email') IS NOT NULL + AND JSON_EXTRACT(client.value, '$.email') != '' + `).Scan(&settingsEmails) + if len(settingsEmails) > 0 { + emails = append(emails, settingsEmails...) + } + return emails, nil } @@ -277,20 +428,23 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo } } - // Secure client ID - for _, client := range clients { - switch inbound.Protocol { - case "trojan": - if client.Password == "" { - return inbound, false, common.NewError("empty client ID") - } - case "shadowsocks": - if client.Email == "" { - return inbound, false, common.NewError("empty client ID") - } - default: - if client.ID == "" { - return inbound, false, common.NewError("empty client ID") + // Secure client ID (only validate if clients are provided) + // Allow creating inbounds without clients + if len(clients) > 0 { + for _, client := range clients { + switch inbound.Protocol { + case "trojan": + if client.Password == "" { + return inbound, false, common.NewError("empty client ID") + } + case "shadowsocks": + if client.Email == "" { + return inbound, false, common.NewError("empty client ID") + } + default: + if client.ID == "" { + return inbound, false, common.NewError("empty client ID") + } } } } diff --git a/web/service/setting.go b/web/service/setting.go index bde01be6..4d64dcd3 100644 --- a/web/service/setting.go +++ b/web/service/setting.go @@ -96,6 +96,8 @@ var defaultValueMap = map[string]string{ "ldapDefaultLimitIP": "0", // Multi-node mode "multiNodeMode": "false", // "true" for multi-mode, "false" for single-mode + // HWID tracking mode + "hwidMode": "client_header", // "off" = disabled, "client_header" = use x-hwid header (default), "legacy_fingerprint" = deprecated fingerprint-based (deprecated) } // SettingService provides business logic for application settings management. @@ -671,6 +673,40 @@ func (s *SettingService) SetMultiNodeMode(enabled bool) error { return s.setBool("multiNodeMode", enabled) } +// GetHwidMode returns the HWID tracking mode. +// Returns: "off", "client_header", or "legacy_fingerprint" +func (s *SettingService) GetHwidMode() (string, error) { + mode, err := s.getString("hwidMode") + if err != nil { + return "client_header", err // Default to client_header on error + } + // Validate mode + validModes := map[string]bool{ + "off": true, + "client_header": true, + "legacy_fingerprint": true, + } + if !validModes[mode] { + // Invalid mode, return default + return "client_header", nil + } + return mode, nil +} + +// SetHwidMode sets the HWID tracking mode. +// Valid values: "off", "client_header", "legacy_fingerprint" +func (s *SettingService) SetHwidMode(mode string) error { + validModes := map[string]bool{ + "off": true, + "client_header": true, + "legacy_fingerprint": true, + } + if !validModes[mode] { + return common.NewErrorf("invalid hwidMode: %s (must be one of: off, client_header, legacy_fingerprint)", mode) + } + return s.setString("hwidMode", mode) +} + func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error { if err := allSetting.CheckValid(); err != nil { return err diff --git a/web/translation/translate.en_US.toml b/web/translation/translate.en_US.toml index f09169d5..237db735 100644 --- a/web/translation/translate.en_US.toml +++ b/web/translation/translate.en_US.toml @@ -23,6 +23,11 @@ "indefinite" = "Indefinite" "unlimited" = "Unlimited" "none" = "None" +"hwidSettings" = "HWID Settings" +"hwidEnabled" = "Enable HWID Restriction" +"maxHwid" = "Max Allowed Devices (HWID)" +"hwidBetaWarningTitle" = "Beta Feature" +"hwidBetaWarningDesc" = "HWID tracking is currently in beta version and works only with happ and v2raytun clients. Other clients may not support HWID registration." "qrCode" = "QR Code" "info" = "More Information" "edit" = "Edit" @@ -72,6 +77,8 @@ "emptyBalancersDesc" = "No added balancers." "emptyReverseDesc" = "No added reverse proxies." "somethingWentWrong" = "Something went wrong" +"active" = "Active" +"inactive" = "Inactive" [subscription] "title" = "Subscription info" @@ -87,18 +94,6 @@ "unlimited" = "Unlimited" "noExpiry" = "No expiry" -[menu] -"theme" = "Theme" -"dark" = "Dark" -"ultraDark" = "Ultra Dark" -"dashboard" = "Overview" -"inbounds" = "Inbounds" -"settings" = "Panel Settings" -"xray" = "Xray Configs" -"nodes" = "Nodes" -"logout" = "Log Out" -"link" = "Manage" - [pages.login] "hello" = "Hello" "title" = "Welcome" @@ -662,6 +657,90 @@ "mappingError" = "Failed to get node mapping" "invalidInboundId" = "Invalid inbound ID" +[pages.clients] +"title" = "Clients Management" +"addClient" = "Add Client" +"operate" = "Actions" +"email" = "Email" +"inbounds" = "Assigned Inbounds" +"traffic" = "Traffic" +"expiryTime" = "Expiry Time" +"enable" = "Enabled" +"loadError" = "Failed to load clients" +"deleteConfirm" = "Confirm Deletion" +"deleteConfirmText" = "Are you sure you want to delete this client?" +"deleteSuccess" = "Client deleted successfully" +"deleteError" = "Failed to delete client" +"updateSuccess" = "Client updated successfully" +"updateError" = "Failed to update client" +"addClientNotImplemented" = "Add client functionality will be implemented soon" +"editClientNotImplemented" = "Edit client functionality will be implemented soon" +"editClient" = "Edit Client" +"addSuccess" = "Client added successfully" +"addError" = "Failed to add client" +"emailRequired" = "Email is required" +"maxHwidDesc" = "Set 0 for unlimited devices. If a new device connects and the limit is reached, the connection will be blocked." +"registeredHwids" = "Registered Devices" +"registeredHwidsDesc" = "List of hardware IDs (devices) that have connected to this client. Only active devices count towards the limit." +"noHwidsRegistered" = "No devices registered yet." +"confirmDeleteHwid" = "Are you sure you want to delete this device? This will allow a new device to connect if the limit is not reached." +"hwidDeleteSuccess" = "Device deleted successfully." +"hwidDeleteError" = "Failed to delete device." +"deviceInfo" = "Device Information" +"firstSeen" = "First Seen" +"lastSeen" = "Last Seen" +"actions" = "Actions" + +[pages.clients.toasts] +"clientCreateSuccess" = "Client created successfully" +"clientUpdateSuccess" = "Client updated successfully" + +[pages.hosts] +"title" = "Hosts Management" +"addNewHost" = "Add New Host" +"addHost" = "Add Host" +"hostName" = "Host Name" +"hostAddress" = "Host Address" +"hostPort" = "Port" +"hostProtocol" = "Protocol" +"operate" = "Actions" +"name" = "Name" +"address" = "Address" +"port" = "Port" +"protocol" = "Protocol" +"assignedInbounds" = "Assigned Inbounds" +"enable" = "Enabled" +"multiNodeModeRequired" = "Multi-Node Mode must be enabled to manage hosts" +"enterHostNameAndAddress" = "Please enter host name and address" +"enterHostName" = "Please enter host name" +"enterHostAddress" = "Please enter host address" +"editHost" = "Edit Host" +"modalNotAvailable" = "Host modal is not available" +"loadError" = "Failed to load hosts" +"deleteConfirm" = "Confirm Deletion" +"deleteConfirmText" = "Are you sure you want to delete this host?" +"deleteSuccess" = "Host deleted successfully" +"deleteError" = "Failed to delete host" +"updateSuccess" = "Host updated successfully" +"updateError" = "Failed to update host" +"addSuccess" = "Host added successfully" +"addError" = "Failed to add host" +"editHostNotImplemented" = "Edit host functionality will be implemented soon" + +[menu] +"theme" = "Theme" +"dark" = "Dark" +"ultraDark" = "Ultra Dark" +"dashboard" = "Overview" +"inbounds" = "Inbounds" +"clients" = "Clients" +"settings" = "Panel Settings" +"xray" = "Xray Configs" +"nodes" = "Nodes" +"hosts" = "Hosts" +"logout" = "Log Out" +"link" = "Manage" + [pages.settings.toasts] "modifySettings" = "The parameters have been changed." "getSettings" = "An error occurred while retrieving parameters." diff --git a/web/translation/translate.ru_RU.toml b/web/translation/translate.ru_RU.toml index 88c421e3..bec5f449 100644 --- a/web/translation/translate.ru_RU.toml +++ b/web/translation/translate.ru_RU.toml @@ -23,6 +23,11 @@ "indefinite" = "Бесконечно" "unlimited" = "Безлимит" "none" = "Пусто" +"hwidSettings" = "Настройки HWID" +"hwidEnabled" = "Включить ограничение по HWID" +"maxHwid" = "Максимум устройств (HWID)" +"hwidBetaWarningTitle" = "Бета-функция" +"hwidBetaWarningDesc" = "Отслеживание HWID находится в бета-версии и работает только с клиентами happ и v2raytun. Другие клиенты могут не поддерживать регистрацию HWID." "qrCode" = "QR-код" "info" = "Информация" "edit" = "Изменить" @@ -72,6 +77,8 @@ "emptyBalancersDesc" = "Нет добавленных балансировщиков." "emptyReverseDesc" = "Нет добавленных реверс-прокси." "somethingWentWrong" = "Что-то пошло не так" +"active" = "Активен" +"inactive" = "Неактивен" [subscription] "title" = "Информация о подписке" @@ -87,18 +94,6 @@ "unlimited" = "Неограниченно" "noExpiry" = "Бессрочно" -[menu] -"theme" = "Тема" -"dark" = "Темная" -"ultraDark" = "Очень темная" -"dashboard" = "Дашборд" -"inbounds" = "Подключения" -"settings" = "Настройки" -"xray" = "Настройки Xray" -"nodes" = "Ноды" -"logout" = "Выход" -"link" = "Управление" - [pages.login] "hello" = "Привет!" "title" = "Добро пожаловать!" @@ -662,6 +657,90 @@ "mappingError" = "Не удалось получить привязку ноды" "invalidInboundId" = "Неверный ID подключения" +[pages.clients] +"title" = "Управление клиентами" +"addClient" = "Добавить клиента" +"operate" = "Действия" +"email" = "Email" +"inbounds" = "Назначенные подключения" +"traffic" = "Трафик" +"expiryTime" = "Срок действия" +"enable" = "Включено" +"loadError" = "Не удалось загрузить список клиентов" +"deleteConfirm" = "Подтверждение удаления" +"deleteConfirmText" = "Вы уверены, что хотите удалить этого клиента?" +"deleteSuccess" = "Клиент успешно удален" +"deleteError" = "Не удалось удалить клиента" +"updateSuccess" = "Клиент успешно обновлен" +"updateError" = "Не удалось обновить клиента" +"addClientNotImplemented" = "Функция добавления клиента будет реализована в ближайшее время" +"editClientNotImplemented" = "Функция редактирования клиента будет реализована в ближайшее время" +"editClient" = "Редактировать клиента" +"addSuccess" = "Клиент успешно добавлен" +"addError" = "Не удалось добавить клиента" +"emailRequired" = "Email обязателен для заполнения" +"maxHwidDesc" = "Установите 0 для неограниченного количества устройств. Если новое устройство подключается и лимит достигнут, подключение будет заблокировано." +"registeredHwids" = "Зарегистрированные устройства" +"registeredHwidsDesc" = "Список аппаратных ID (устройств), которые подключались к этому клиенту. Только активные устройства учитываются в лимите." +"noHwidsRegistered" = "Устройства еще не зарегистрированы." +"confirmDeleteHwid" = "Вы уверены, что хотите удалить это устройство? Это позволит новому устройству подключиться, если лимит не достигнут." +"hwidDeleteSuccess" = "Устройство успешно удалено." +"hwidDeleteError" = "Не удалось удалить устройство." +"deviceInfo" = "Информация об устройстве" +"firstSeen" = "Первое подключение" +"lastSeen" = "Последнее подключение" +"actions" = "Действия" + +[pages.clients.toasts] +"clientCreateSuccess" = "Клиент успешно создан" +"clientUpdateSuccess" = "Клиент успешно обновлен" + +[pages.hosts] +"title" = "Управление хостами" +"addNewHost" = "Добавить новый хост" +"addHost" = "Добавить хост" +"hostName" = "Имя хоста" +"hostAddress" = "Адрес хоста" +"hostPort" = "Порт" +"hostProtocol" = "Протокол" +"operate" = "Действия" +"name" = "Имя" +"address" = "Адрес" +"port" = "Порт" +"protocol" = "Протокол" +"assignedInbounds" = "Назначенные подключения" +"enable" = "Включено" +"multiNodeModeRequired" = "Для управления хостами должен быть включен режим Multi-Node" +"enterHostNameAndAddress" = "Пожалуйста, введите имя и адрес хоста" +"enterHostName" = "Пожалуйста, введите имя хоста" +"enterHostAddress" = "Пожалуйста, введите адрес хоста" +"editHost" = "Редактировать хост" +"modalNotAvailable" = "Модальное окно хоста недоступно" +"loadError" = "Не удалось загрузить список хостов" +"deleteConfirm" = "Подтверждение удаления" +"deleteConfirmText" = "Вы уверены, что хотите удалить этот хост?" +"deleteSuccess" = "Хост успешно удален" +"deleteError" = "Не удалось удалить хост" +"updateSuccess" = "Хост успешно обновлен" +"updateError" = "Не удалось обновить хост" +"addSuccess" = "Хост успешно добавлен" +"addError" = "Не удалось добавить хост" +"editHostNotImplemented" = "Функция редактирования хоста будет реализована в ближайшее время" + +[menu] +"theme" = "Тема" +"dark" = "Темная" +"ultraDark" = "Очень темная" +"dashboard" = "Обзор" +"inbounds" = "Подключения" +"clients" = "Клиенты" +"settings" = "Настройки панели" +"xray" = "Конфигурация Xray" +"nodes" = "Ноды" +"hosts" = "Хосты" +"logout" = "Выйти" +"link" = "Управление" + [pages.settings.toasts] "modifySettings" = "Настройки изменены" "getSettings" = "Произошла ошибка при получении параметров." diff --git a/web/web.go b/web/web.go index 34faf6a9..7257f173 100644 --- a/web/web.go +++ b/web/web.go @@ -320,6 +320,9 @@ func (s *Server) startTask() { // check client ips from log file every 10 sec s.cron.AddJob("@every 10s", job.NewCheckClientIpJob()) + + // Check client HWIDs from log file every 30 seconds + s.cron.AddJob("@every 30s", job.NewCheckClientHWIDJob()) // check client ips from log file every day s.cron.AddJob("@daily", job.NewClearLogsJob())