diff --git a/database/model/model.go b/database/model/model.go index 76910d7a..4249d431 100644 --- a/database/model/model.go +++ b/database/model/model.go @@ -135,9 +135,10 @@ type ClientEntity struct { 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 + TotalGB float64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB (supports decimal values like 0.01 for MB) ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp Enable bool `json:"enable" form:"enable"` // Whether the client is enabled + Status string `json:"status" form:"status" gorm:"default:active"` // Client status: active, expired_traffic, expired_time 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 @@ -148,11 +149,11 @@ type ClientEntity struct { // 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 + // Traffic statistics (stored directly in ClientEntity table) + Up int64 `json:"up,omitempty" form:"-" gorm:"default:0"` // Upload traffic in bytes + Down int64 `json:"down,omitempty" form:"-" gorm:"default:0"` // Download traffic in bytes + AllTime int64 `json:"allTime,omitempty" form:"-" gorm:"default:0"` // All-time traffic usage + LastOnline int64 `json:"lastOnline,omitempty" form:"-" gorm:"default:0"` // 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 diff --git a/sub/subService.go b/sub/subService.go index 7ed97f25..a878a2ca 100644 --- a/sub/subService.go +++ b/sub/subService.go @@ -62,6 +62,36 @@ func (s *SubService) GetSubs(subId string, host string, c *gin.Context) ([]strin } 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) + + // Check traffic limits and expiry time before returning subscription + // Traffic statistics are now stored directly in ClientEntity + now := time.Now().Unix() * 1000 + totalUsed := clientEntity.Up + clientEntity.Down + trafficLimit := int64(clientEntity.TotalGB * 1024 * 1024 * 1024) + trafficExceeded := clientEntity.TotalGB > 0 && totalUsed >= trafficLimit + timeExpired := clientEntity.ExpiryTime > 0 && clientEntity.ExpiryTime <= now + + // Check if client exceeded limits - set status but keep Enable = true to allow subscription + if trafficExceeded || timeExpired { + // Client exceeded limits - set status but keep Enable = true + // Subscription should still work to show traffic information to client + status := "expired_traffic" + if timeExpired { + status = "expired_time" + } + + // Update status if not already set + if clientEntity.Status != status { + db.Model(&model.ClientEntity{}).Where("id = ?", clientEntity.Id).Update("status", status) + clientEntity.Status = status + logger.Warningf("GetSubs: Client %s (subId: %s) exceeded limits - set status to %s: trafficExceeded=%v, timeExpired=%v, totalUsed=%d, total=%d", + clientEntity.Email, subId, status, trafficExceeded, timeExpired, totalUsed, trafficLimit) + } + // Continue to generate subscription - client will be blocked in Xray config, not in subscription + } + + // Note: We don't block subscription even if client has expired status + // Subscription provides traffic information, and client blocking is handled in Xray config } // Register HWID from headers if context is provided and client is found @@ -106,7 +136,16 @@ func (s *SubService) GetSubs(subId string, host string, c *gin.Context) ([]strin result = append(result, linkLine) } } - ct := s.getClientTraffics(inbound.ClientStats, clientEntity.Email) + // Create ClientTraffic from ClientEntity for statistics (traffic is stored in ClientEntity now) + trafficLimit := int64(clientEntity.TotalGB * 1024 * 1024 * 1024) + ct := xray.ClientTraffic{ + Email: clientEntity.Email, + Up: clientEntity.Up, + Down: clientEntity.Down, + Total: trafficLimit, + ExpiryTime: clientEntity.ExpiryTime, + LastOnline: clientEntity.LastOnline, + } clientTraffics = append(clientTraffics, ct) if ct.LastOnline > lastOnline { lastOnline = ct.LastOnline @@ -122,6 +161,39 @@ func (s *SubService) GetSubs(subId string, host string, c *gin.Context) ([]strin } for _, client := range clients { if client.Enable && client.SubID == subId { + // Use ClientEntity for traffic (new architecture only) + var clientEntity model.ClientEntity + err = db.Where("LOWER(email) = ?", strings.ToLower(client.Email)).First(&clientEntity).Error + if err != nil { + // Client not found in ClientEntity - skip (old architecture clients should be migrated) + logger.Warningf("GetSubs: Client %s (subId: %s) not found in ClientEntity - skipping", + client.Email, subId) + continue + } + + // Check traffic limits from ClientEntity + now := time.Now().Unix() * 1000 + totalUsed := clientEntity.Up + clientEntity.Down + trafficLimit := int64(clientEntity.TotalGB * 1024 * 1024 * 1024) + trafficExceeded := clientEntity.TotalGB > 0 && totalUsed >= trafficLimit + timeExpired := clientEntity.ExpiryTime > 0 && clientEntity.ExpiryTime <= now + + if trafficExceeded || timeExpired || !clientEntity.Enable { + logger.Warningf("GetSubs: Client %s (subId: %s) exceeded limits or disabled - skipping", + client.Email, subId) + continue + } + + // Create ClientTraffic from ClientEntity for statistics + clientTraffic := xray.ClientTraffic{ + Email: clientEntity.Email, + Up: clientEntity.Up, + Down: clientEntity.Down, + Total: trafficLimit, + ExpiryTime: clientEntity.ExpiryTime, + LastOnline: clientEntity.LastOnline, + } + link := s.getLink(inbound, client.Email) // Split link by newline to handle multiple links (for multiple nodes) linkLines := strings.Split(link, "\n") @@ -132,6 +204,9 @@ func (s *SubService) GetSubs(subId string, host string, c *gin.Context) ([]strin } } ct := s.getClientTraffics(inbound.ClientStats, client.Email) + if ct.Email == "" { + ct = clientTraffic + } clientTraffics = append(clientTraffics, ct) if ct.LastOnline > lastOnline { lastOnline = ct.LastOnline diff --git a/web/controller/client.go b/web/controller/client.go index a1417c9d..d0aba65f 100644 --- a/web/controller/client.go +++ b/web/controller/client.go @@ -38,6 +38,9 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) { g.POST("/add", a.addClient) g.POST("/update/:id", a.updateClient) g.POST("/del/:id", a.deleteClient) + g.POST("/resetAllTraffics", a.resetAllClientTraffics) + g.POST("/resetTraffic/:id", a.resetClientTraffic) + g.POST("/delDepletedClients", a.delDepletedClients) } // getClients retrieves the list of all clients for the current user. @@ -163,6 +166,17 @@ func (a *ClientController) updateClient(c *gin.Context) { user := session.GetLoginUser(c) + // Get existing client first to preserve fields not being updated + existing, err := a.clientService.GetClient(id) + if err != nil { + jsonMsg(c, "Client not found", err) + return + } + if existing.UserId != user.Id { + jsonMsg(c, "Client not found or access denied", nil) + return + } + // Extract inboundIds from JSON or form data var inboundIdsFromJSON []int var hasInboundIdsInJSON bool @@ -198,11 +212,121 @@ func (a *ClientController) updateClient(c *gin.Context) { } } - client := &model.ClientEntity{} - err = c.ShouldBind(client) - if err != nil { - jsonMsg(c, "Invalid client data", err) - return + // Use existing client as base and update only provided fields + client := existing + + // Try to bind only provided fields - use ShouldBindJSON for JSON requests + if c.ContentType() == "application/json" { + var updateData map[string]interface{} + if err := c.ShouldBindJSON(&updateData); err == nil { + // Update only fields that are present in the request + if email, ok := updateData["email"].(string); ok && email != "" { + client.Email = email + } + if uuid, ok := updateData["uuid"].(string); ok && uuid != "" { + client.UUID = uuid + } + if security, ok := updateData["security"].(string); ok && security != "" { + client.Security = security + } + if password, ok := updateData["password"].(string); ok && password != "" { + client.Password = password + } + if flow, ok := updateData["flow"].(string); ok && flow != "" { + client.Flow = flow + } + if limitIP, ok := updateData["limitIp"].(float64); ok { + client.LimitIP = int(limitIP) + } else if limitIP, ok := updateData["limitIp"].(int); ok { + client.LimitIP = limitIP + } + if totalGB, ok := updateData["totalGB"].(float64); ok { + client.TotalGB = totalGB + } else if totalGB, ok := updateData["totalGB"].(int); ok { + client.TotalGB = float64(totalGB) + } else if totalGB, ok := updateData["totalGB"].(int64); ok { + client.TotalGB = float64(totalGB) + } + if expiryTime, ok := updateData["expiryTime"].(float64); ok { + client.ExpiryTime = int64(expiryTime) + } else if expiryTime, ok := updateData["expiryTime"].(int64); ok { + client.ExpiryTime = expiryTime + } + if enable, ok := updateData["enable"].(bool); ok { + client.Enable = enable + } + if tgID, ok := updateData["tgId"].(float64); ok { + client.TgID = int64(tgID) + } else if tgID, ok := updateData["tgId"].(int64); ok { + client.TgID = tgID + } + if subID, ok := updateData["subId"].(string); ok && subID != "" { + client.SubID = subID + } + if comment, ok := updateData["comment"].(string); ok && comment != "" { + client.Comment = comment + } + if reset, ok := updateData["reset"].(float64); ok { + client.Reset = int(reset) + } else if reset, ok := updateData["reset"].(int); ok { + client.Reset = reset + } + if hwidEnabled, ok := updateData["hwidEnabled"].(bool); ok { + client.HWIDEnabled = hwidEnabled + } + if maxHwid, ok := updateData["maxHwid"].(float64); ok { + client.MaxHWID = int(maxHwid) + } else if maxHwid, ok := updateData["maxHwid"].(int); ok { + client.MaxHWID = maxHwid + } + } + } else { + // For form data, use ShouldBind + updateClient := &model.ClientEntity{} + if err := c.ShouldBind(updateClient); err == nil { + // Update only non-empty fields + if updateClient.Email != "" { + client.Email = updateClient.Email + } + if updateClient.UUID != "" { + client.UUID = updateClient.UUID + } + if updateClient.Security != "" { + client.Security = updateClient.Security + } + if updateClient.Password != "" { + client.Password = updateClient.Password + } + if updateClient.Flow != "" { + client.Flow = updateClient.Flow + } + if updateClient.LimitIP > 0 { + client.LimitIP = updateClient.LimitIP + } + if updateClient.TotalGB > 0 { + client.TotalGB = updateClient.TotalGB + } + if updateClient.ExpiryTime != 0 { + client.ExpiryTime = updateClient.ExpiryTime + } + // Always update enable if it's in the request (even if false) + enableStr := c.PostForm("enable") + if enableStr != "" { + client.Enable = enableStr == "true" || enableStr == "1" + } + if updateClient.TgID > 0 { + client.TgID = updateClient.TgID + } + if updateClient.SubID != "" { + client.SubID = updateClient.SubID + } + if updateClient.Comment != "" { + client.Comment = updateClient.Comment + } + if updateClient.Reset > 0 { + client.Reset = updateClient.Reset + } + } } // Set inboundIds from JSON if available @@ -272,3 +396,72 @@ func (a *ClientController) deleteClient(c *gin.Context) { } } } + +// resetAllClientTraffics resets traffic counters for all clients of the current user. +func (a *ClientController) resetAllClientTraffics(c *gin.Context) { + user := session.GetLoginUser(c) + needRestart, err := a.clientService.ResetAllClientTraffics(user.Id) + if err != nil { + logger.Errorf("Failed to reset all client traffics: %v", err) + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetAllClientTrafficSuccess"), 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 resetting all client traffics: %v", err) + } + } +} + +// resetClientTraffic resets traffic counter for a specific client. +func (a *ClientController) resetClientTraffic(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.ResetClientTraffic(user.Id, id) + if err != nil { + logger.Errorf("Failed to reset client traffic: %v", err) + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + + jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetInboundClientTrafficSuccess"), 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 traffic reset: %v", err) + } + } +} + +// delDepletedClients deletes clients that have exhausted their traffic limits or expired. +func (a *ClientController) delDepletedClients(c *gin.Context) { + user := session.GetLoginUser(c) + count, needRestart, err := a.clientService.DelDepletedClients(user.Id) + if err != nil { + logger.Errorf("Failed to delete depleted clients: %v", err) + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + + if count > 0 { + jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.delDepletedClientsSuccess"), 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 deleting depleted clients: %v", err) + } + } + } else { + jsonMsg(c, "No depleted clients found", nil) + } +} diff --git a/web/controller/util.go b/web/controller/util.go index b11203bd..9b39581e 100644 --- a/web/controller/util.go +++ b/web/controller/util.go @@ -8,6 +8,7 @@ import ( "github.com/mhsanaei/3x-ui/v2/config" "github.com/mhsanaei/3x-ui/v2/logger" "github.com/mhsanaei/3x-ui/v2/web/entity" + "github.com/mhsanaei/3x-ui/v2/web/service" "github.com/gin-gonic/gin" ) @@ -92,6 +93,16 @@ func getContext(h gin.H) gin.H { a := gin.H{ "cur_ver": config.GetVersion(), } + + // Add multiNodeMode to context for all pages + settingService := service.SettingService{} + multiNodeMode, err := settingService.GetMultiNodeMode() + if err != nil { + // If error, default to false (single mode) + multiNodeMode = false + } + a["multiNodeMode"] = multiNodeMode + for key, value := range h { a[key] = value } diff --git a/web/html/clients.html b/web/html/clients.html index e9e6f993..5c7c3419 100644 --- a/web/html/clients.html +++ b/web/html/clients.html @@ -10,37 +10,76 @@ - -

{{ i18n "pages.clients.title" }}

- -
- {{ i18n "pages.clients.addClient" }} - {{ i18n "refresh" }} - - - - - -
- - + + + +
+ + + + + + + {{ i18n "none" }} + {{ i18n "disabled" }} + {{ i18n "depleted" }} + {{ i18n "depletingSoon" }} + {{ i18n "online" }} + +
+