From 8b72cfd870bd2f98828963c5a7c7af2535641f2a Mon Sep 17 00:00:00 2001 From: Mohamadhosein Moazennia Date: Wed, 18 Feb 2026 20:11:54 +0330 Subject: [PATCH] feat: add client center for centralized client management --- database/db.go | 2 + database/model/model.go | 27 ++ web/controller/api.go | 5 + web/controller/client_center.go | 127 +++++++++ web/controller/xui.go | 6 + web/html/clients.html | 304 ++++++++++++++++++++ web/html/component/aSidebar.html | 7 +- web/service/client_center.go | 459 +++++++++++++++++++++++++++++++ 8 files changed, 936 insertions(+), 1 deletion(-) create mode 100644 web/controller/client_center.go create mode 100644 web/html/clients.html create mode 100644 web/service/client_center.go diff --git a/database/db.go b/database/db.go index 6b579dd9..39d21db1 100644 --- a/database/db.go +++ b/database/db.go @@ -36,6 +36,8 @@ func initModels() error { &model.OutboundTraffics{}, &model.Setting{}, &model.InboundClientIps{}, + &model.MasterClient{}, + &model.MasterClientInbound{}, &xray.ClientTraffic{}, &model.HistoryOfSeeders{}, } diff --git a/database/model/model.go b/database/model/model.go index 6225df52..5eb1fffa 100644 --- a/database/model/model.go +++ b/database/model/model.go @@ -122,3 +122,30 @@ type Client struct { CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp } + +// MasterClient stores centralized client profile data managed from the Clients page. +// Actual protocol clients are still created per inbound and linked via MasterClientInbound. +type MasterClient struct { + Id int `json:"id" gorm:"primaryKey;autoIncrement"` + UserId int `json:"userId" gorm:"index"` + Name string `json:"name"` + EmailPrefix string `json:"emailPrefix" gorm:"index"` + TotalGB int64 `json:"totalGB"` + ExpiryTime int64 `json:"expiryTime"` + LimitIP int `json:"limitIp"` + Enable bool `json:"enable" gorm:"default:true"` + Comment string `json:"comment"` + CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"` + UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"` +} + +// MasterClientInbound maps a master client to a concrete inbound client record. +type MasterClientInbound struct { + Id int `json:"id" gorm:"primaryKey;autoIncrement"` + MasterClientId int `json:"masterClientId" gorm:"index;uniqueIndex:idx_master_inbound,priority:1"` + InboundId int `json:"inboundId" gorm:"index;uniqueIndex:idx_master_inbound,priority:2"` + AssignmentEmail string `json:"assignmentEmail" gorm:"uniqueIndex"` + ClientKey string `json:"clientKey"` // id/password/email key used by inbound update/delete endpoints + CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"` + UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"` +} diff --git a/web/controller/api.go b/web/controller/api.go index 1a39f8ed..df5e292e 100644 --- a/web/controller/api.go +++ b/web/controller/api.go @@ -14,6 +14,7 @@ type APIController struct { BaseController inboundController *InboundController serverController *ServerController + clientController *ClientCenterController Tgbot service.Tgbot } @@ -48,6 +49,10 @@ func (a *APIController) initRouter(g *gin.RouterGroup) { server := api.Group("/server") a.serverController = NewServerController(server) + // Client Center API + clients := api.Group("/clients") + a.clientController = NewClientCenterController(clients) + // Extra routes api.GET("/backuptotgbot", a.BackuptoTgbot) } diff --git a/web/controller/client_center.go b/web/controller/client_center.go new file mode 100644 index 00000000..bc89bc5b --- /dev/null +++ b/web/controller/client_center.go @@ -0,0 +1,127 @@ +package controller + +import ( + "strconv" + + "github.com/gin-gonic/gin" + "github.com/mhsanaei/3x-ui/v2/web/service" + "github.com/mhsanaei/3x-ui/v2/web/session" +) + +// ClientCenterController manages centralized client profiles and inbound assignments. +type ClientCenterController struct { + service service.ClientCenterService +} + +func NewClientCenterController(g *gin.RouterGroup) *ClientCenterController { + a := &ClientCenterController{} + a.initRouter(g) + return a +} + +func (a *ClientCenterController) initRouter(g *gin.RouterGroup) { + g.GET("/list", a.list) + g.GET("/inbounds", a.inbounds) + g.POST("/add", a.add) + g.POST("/update/:id", a.update) + g.POST("/del/:id", a.del) +} + +type clientCenterUpsertForm struct { + Name string `form:"name"` + EmailPrefix string `form:"emailPrefix"` + TotalGB int64 `form:"totalGB"` + ExpiryTime int64 `form:"expiryTime"` + LimitIP int `form:"limitIp"` + Enable bool `form:"enable"` + Comment string `form:"comment"` + InboundIds []int `form:"inboundIds"` +} + +func (a *ClientCenterController) list(c *gin.Context) { + user := session.GetLoginUser(c) + items, err := a.service.ListMasterClients(user.Id) + if err != nil { + jsonMsg(c, "get client center list", err) + return + } + jsonObj(c, items, nil) +} + +func (a *ClientCenterController) inbounds(c *gin.Context) { + user := session.GetLoginUser(c) + items, err := a.service.ListInbounds(user.Id) + if err != nil { + jsonMsg(c, "get inbounds", err) + return + } + jsonObj(c, items, nil) +} + +func (a *ClientCenterController) add(c *gin.Context) { + form := &clientCenterUpsertForm{} + if err := c.ShouldBind(form); err != nil { + jsonMsg(c, "invalid client payload", err) + return + } + user := session.GetLoginUser(c) + item, err := a.service.CreateMasterClient(user.Id, service.UpsertMasterClientInput{ + Name: form.Name, + EmailPrefix: form.EmailPrefix, + TotalGB: form.TotalGB, + ExpiryTime: form.ExpiryTime, + LimitIP: form.LimitIP, + Enable: form.Enable, + Comment: form.Comment, + InboundIds: form.InboundIds, + }) + if err != nil { + jsonMsg(c, "create master client", err) + return + } + jsonMsgObj(c, "master client created", item, nil) +} + +func (a *ClientCenterController) update(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + jsonMsg(c, "invalid client id", err) + return + } + form := &clientCenterUpsertForm{} + if err := c.ShouldBind(form); err != nil { + jsonMsg(c, "invalid client payload", err) + return + } + user := session.GetLoginUser(c) + item, err := a.service.UpdateMasterClient(user.Id, id, service.UpsertMasterClientInput{ + Name: form.Name, + EmailPrefix: form.EmailPrefix, + TotalGB: form.TotalGB, + ExpiryTime: form.ExpiryTime, + LimitIP: form.LimitIP, + Enable: form.Enable, + Comment: form.Comment, + InboundIds: form.InboundIds, + }) + if err != nil { + jsonMsg(c, "update master client", err) + return + } + jsonMsgObj(c, "master client updated", item, nil) +} + +func (a *ClientCenterController) del(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + jsonMsg(c, "invalid client id", err) + return + } + user := session.GetLoginUser(c) + err = a.service.DeleteMasterClient(user.Id, id) + if err != nil { + jsonMsg(c, "delete master client", err) + return + } + jsonMsg(c, "master client deleted", nil) +} diff --git a/web/controller/xui.go b/web/controller/xui.go index 51502900..cc8ad21d 100644 --- a/web/controller/xui.go +++ b/web/controller/xui.go @@ -26,6 +26,7 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) { g.GET("/", a.index) g.GET("/inbounds", a.inbounds) + g.GET("/clients", a.clients) g.GET("/settings", a.settings) g.GET("/xray", a.xraySettings) @@ -43,6 +44,11 @@ func (a *XUIController) inbounds(c *gin.Context) { html(c, "inbounds.html", "pages.inbounds.title", nil) } +// clients renders the centralized clients management page. +func (a *XUIController) clients(c *gin.Context) { + html(c, "clients.html", "clients", nil) +} + // settings renders the settings management page. func (a *XUIController) settings(c *gin.Context) { html(c, "settings.html", "pages.settings.title", nil) diff --git a/web/html/clients.html b/web/html/clients.html new file mode 100644 index 00000000..2b4e7e57 --- /dev/null +++ b/web/html/clients.html @@ -0,0 +1,304 @@ +{{ template "page/head_start" .}} +{{ template "page/head_end" .}} + +{{ template "page/body_start" .}} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + [[ ib.remark ]] (port [[ ib.port ]], [[ ib.protocol ]]) + + + + + + Enabled + + + + + + + + + + +{{template "page/body_scripts" .}} +{{template "component/aSidebar" .}} +{{template "component/aThemeSwitch" .}} + +{{template "page/body_end" .}} diff --git a/web/html/component/aSidebar.html b/web/html/component/aSidebar.html index b69c8f3f..0f0f455f 100644 --- a/web/html/component/aSidebar.html +++ b/web/html/component/aSidebar.html @@ -54,6 +54,11 @@ icon: 'user', title: '{{ i18n "menu.inbounds"}}' }, + { + key: '{{ .base_path }}panel/clients', + icon: 'team', + title: 'Clients' + }, { key: '{{ .base_path }}panel/settings', icon: 'setting', @@ -100,4 +105,4 @@ template: `{{template "component/sidebar/content"}}`, }); -{{end}} \ No newline at end of file +{{end}} diff --git a/web/service/client_center.go b/web/service/client_center.go new file mode 100644 index 00000000..92bb6d5d --- /dev/null +++ b/web/service/client_center.go @@ -0,0 +1,459 @@ +package service + +import ( + "encoding/json" + "errors" + "fmt" + "sort" + "strconv" + "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/util/common" + "github.com/mhsanaei/3x-ui/v2/util/random" + "github.com/mhsanaei/3x-ui/v2/xray" + "gorm.io/gorm" +) + +// ClientCenterService provides client-first management on top of inbound-scoped clients. +// It stores master client profiles and synchronizes assigned inbound clients safely. +type ClientCenterService struct { + inboundService InboundService +} + +type ClientCenterInboundInfo struct { + Id int `json:"id"` + Remark string `json:"remark"` + Protocol string `json:"protocol"` + Port int `json:"port"` + Enable bool `json:"enable"` +} + +type MasterClientView struct { + Id int `json:"id"` + Name string `json:"name"` + EmailPrefix string `json:"emailPrefix"` + TotalGB int64 `json:"totalGB"` + ExpiryTime int64 `json:"expiryTime"` + LimitIP int `json:"limitIp"` + Enable bool `json:"enable"` + Comment string `json:"comment"` + Assignments []ClientCenterInboundInfo `json:"assignments"` + UsageUp int64 `json:"usageUp"` + UsageDown int64 `json:"usageDown"` + UsageAllTime int64 `json:"usageAllTime"` + LastSeenOnlineAt int64 `json:"lastSeenOnlineAt"` +} + +type UpsertMasterClientInput struct { + Name string + EmailPrefix string + TotalGB int64 + ExpiryTime int64 + LimitIP int + Enable bool + Comment string + InboundIds []int +} + +func (s *ClientCenterService) ListInbounds(userId int) ([]ClientCenterInboundInfo, error) { + inbounds, err := s.inboundService.GetInbounds(userId) + if err != nil { + return nil, err + } + out := make([]ClientCenterInboundInfo, 0, len(inbounds)) + for _, inbound := range inbounds { + if !supportsManagedClients(inbound.Protocol) { + continue + } + out = append(out, ClientCenterInboundInfo{ + Id: inbound.Id, + Remark: inbound.Remark, + Protocol: string(inbound.Protocol), + Port: inbound.Port, + Enable: inbound.Enable, + }) + } + sort.Slice(out, func(i, j int) bool { return out[i].Id < out[j].Id }) + return out, nil +} + +func (s *ClientCenterService) ListMasterClients(userId int) ([]MasterClientView, error) { + db := database.GetDB() + masters := make([]model.MasterClient, 0) + if err := db.Where("user_id = ?", userId).Order("id asc").Find(&masters).Error; err != nil { + return nil, err + } + if len(masters) == 0 { + return []MasterClientView{}, nil + } + + masterIDs := make([]int, 0, len(masters)) + for _, m := range masters { + masterIDs = append(masterIDs, m.Id) + } + + links := make([]model.MasterClientInbound, 0) + if err := db.Where("master_client_id IN ?", masterIDs).Order("id asc").Find(&links).Error; err != nil { + return nil, err + } + + inbounds, err := s.inboundService.GetInbounds(userId) + if err != nil { + return nil, err + } + inboundByID := map[int]*model.Inbound{} + for _, inbound := range inbounds { + inboundByID[inbound.Id] = inbound + } + + emails := make([]string, 0, len(links)) + for _, l := range links { + emails = append(emails, l.AssignmentEmail) + } + trafficByEmail := map[string]xray.ClientTraffic{} + if len(emails) > 0 { + stats := make([]xray.ClientTraffic, 0) + if err := db.Where("email IN ?", emails).Find(&stats).Error; err == nil { + for _, st := range stats { + trafficByEmail[strings.ToLower(st.Email)] = st + } + } + } + + linksByMaster := map[int][]model.MasterClientInbound{} + for _, l := range links { + linksByMaster[l.MasterClientId] = append(linksByMaster[l.MasterClientId], l) + } + + result := make([]MasterClientView, 0, len(masters)) + for _, m := range masters { + view := MasterClientView{ + Id: m.Id, + Name: m.Name, + EmailPrefix: m.EmailPrefix, + TotalGB: m.TotalGB, + ExpiryTime: m.ExpiryTime, + LimitIP: m.LimitIP, + Enable: m.Enable, + Comment: m.Comment, + } + for _, link := range linksByMaster[m.Id] { + if inbound, ok := inboundByID[link.InboundId]; ok { + view.Assignments = append(view.Assignments, ClientCenterInboundInfo{ + Id: inbound.Id, + Remark: inbound.Remark, + Protocol: string(inbound.Protocol), + Port: inbound.Port, + Enable: inbound.Enable, + }) + } + if st, ok := trafficByEmail[strings.ToLower(link.AssignmentEmail)]; ok { + view.UsageUp += st.Up + view.UsageDown += st.Down + view.UsageAllTime += st.AllTime + if st.LastOnline > view.LastSeenOnlineAt { + view.LastSeenOnlineAt = st.LastOnline + } + } + } + sort.Slice(view.Assignments, func(i, j int) bool { return view.Assignments[i].Id < view.Assignments[j].Id }) + result = append(result, view) + } + return result, nil +} + +func (s *ClientCenterService) CreateMasterClient(userId int, input UpsertMasterClientInput) (*model.MasterClient, error) { + normalized, err := normalizeMasterInput(input) + if err != nil { + return nil, err + } + now := time.Now().UnixMilli() + master := &model.MasterClient{ + UserId: userId, + Name: normalized.Name, + EmailPrefix: normalized.EmailPrefix, + TotalGB: normalized.TotalGB, + ExpiryTime: normalized.ExpiryTime, + LimitIP: normalized.LimitIP, + Enable: normalized.Enable, + Comment: normalized.Comment, + CreatedAt: now, + UpdatedAt: now, + } + + db := database.GetDB() + if err := db.Create(master).Error; err != nil { + return nil, err + } + if err := s.syncMasterAssignments(userId, master, normalized.InboundIds); err != nil { + return nil, err + } + return master, nil +} + +func (s *ClientCenterService) UpdateMasterClient(userId, masterID int, input UpsertMasterClientInput) (*model.MasterClient, error) { + normalized, err := normalizeMasterInput(input) + if err != nil { + return nil, err + } + db := database.GetDB() + master := &model.MasterClient{} + if err := db.Where("id = ? AND user_id = ?", masterID, userId).First(master).Error; err != nil { + return nil, err + } + master.Name = normalized.Name + master.EmailPrefix = normalized.EmailPrefix + master.TotalGB = normalized.TotalGB + master.ExpiryTime = normalized.ExpiryTime + master.LimitIP = normalized.LimitIP + master.Enable = normalized.Enable + master.Comment = normalized.Comment + master.UpdatedAt = time.Now().UnixMilli() + if err := db.Save(master).Error; err != nil { + return nil, err + } + if err := s.syncMasterAssignments(userId, master, normalized.InboundIds); err != nil { + return nil, err + } + return master, nil +} + +func (s *ClientCenterService) DeleteMasterClient(userId, masterID int) error { + db := database.GetDB() + master := &model.MasterClient{} + if err := db.Where("id = ? AND user_id = ?", masterID, userId).First(master).Error; err != nil { + return err + } + links := make([]model.MasterClientInbound, 0) + if err := db.Where("master_client_id = ?", masterID).Find(&links).Error; err != nil { + return err + } + + for _, link := range links { + if err := s.removeAssignment(link); err != nil { + return err + } + } + if err := db.Where("master_client_id = ?", masterID).Delete(&model.MasterClientInbound{}).Error; err != nil { + return err + } + return db.Delete(master).Error +} + +func (s *ClientCenterService) syncMasterAssignments(userId int, master *model.MasterClient, desiredInboundIDs []int) error { + db := database.GetDB() + links := make([]model.MasterClientInbound, 0) + if err := db.Where("master_client_id = ?", master.Id).Find(&links).Error; err != nil { + return err + } + + desired := map[int]bool{} + for _, id := range desiredInboundIDs { + desired[id] = true + } + existing := map[int]model.MasterClientInbound{} + for _, l := range links { + existing[l.InboundId] = l + } + + inbounds, err := s.inboundService.GetInbounds(userId) + if err != nil { + return err + } + inboundByID := map[int]*model.Inbound{} + for _, inbound := range inbounds { + inboundByID[inbound.Id] = inbound + } + + for inboundID := range desired { + inbound, ok := inboundByID[inboundID] + if !ok { + return common.NewError("inbound not found for user:", inboundID) + } + if !supportsManagedClients(inbound.Protocol) { + return common.NewError("inbound protocol is not multi-client:", inbound.Protocol) + } + if link, exists := existing[inboundID]; exists { + if err := s.updateAssignment(master, inbound, link); err != nil { + return err + } + continue + } + if err := s.createAssignment(master, inboundID, inbound); err != nil { + return err + } + } + + for inboundID, link := range existing { + if desired[inboundID] { + continue + } + if err := s.removeAssignment(link); err != nil { + return err + } + if err := db.Delete(&link).Error; err != nil { + return err + } + } + return nil +} + +func (s *ClientCenterService) createAssignment(master *model.MasterClient, inboundID int, inbound *model.Inbound) error { + db := database.GetDB() + assignEmail := s.newAssignmentEmail(master.EmailPrefix, inboundID) + client, clientKey := buildProtocolClient(master, assignEmail, inbound.Protocol) + + payload := map[string]any{"clients": []model.Client{client}} + settingsJSON, err := json.Marshal(payload) + if err != nil { + return err + } + data := &model.Inbound{Id: inboundID, Settings: string(settingsJSON)} + if _, err := s.inboundService.AddInboundClient(data); err != nil { + return err + } + + now := time.Now().UnixMilli() + link := &model.MasterClientInbound{ + MasterClientId: master.Id, + InboundId: inboundID, + AssignmentEmail: assignEmail, + ClientKey: clientKey, + CreatedAt: now, + UpdatedAt: now, + } + return db.Create(link).Error +} + +func (s *ClientCenterService) updateAssignment(master *model.MasterClient, inbound *model.Inbound, link model.MasterClientInbound) error { + client, _ := buildProtocolClient(master, link.AssignmentEmail, inbound.Protocol) + payload := map[string]any{"clients": []model.Client{client}} + settingsJSON, err := json.Marshal(payload) + if err != nil { + return err + } + data := &model.Inbound{Id: inbound.Id, Settings: string(settingsJSON)} + if _, err := s.inboundService.UpdateInboundClient(data, link.ClientKey); err != nil { + return err + } + link.UpdatedAt = time.Now().UnixMilli() + return database.GetDB().Save(&link).Error +} + +func (s *ClientCenterService) removeAssignment(link model.MasterClientInbound) error { + _, err := s.inboundService.DelInboundClient(link.InboundId, link.ClientKey) + if err == nil { + return nil + } + if strings.Contains(strings.ToLower(err.Error()), "no client remained") { + return common.NewError("cannot detach from inbound because it would leave inbound without clients") + } + return err +} + +func supportsManagedClients(protocol model.Protocol) bool { + switch protocol { + case model.VMESS, model.VLESS, model.Trojan, model.Shadowsocks: + return true + default: + return false + } +} + +func normalizeMasterInput(input UpsertMasterClientInput) (UpsertMasterClientInput, error) { + input.Name = strings.TrimSpace(input.Name) + input.EmailPrefix = strings.TrimSpace(strings.ToLower(input.EmailPrefix)) + input.Comment = strings.TrimSpace(input.Comment) + if input.Name == "" { + return input, errors.New("name is required") + } + if input.EmailPrefix == "" { + return input, errors.New("email prefix is required") + } + if strings.ContainsAny(input.EmailPrefix, " @") { + return input, errors.New("email prefix cannot contain spaces or '@'") + } + if input.TotalGB < 0 { + return input, errors.New("totalGB cannot be negative") + } + if input.LimitIP < 0 { + return input, errors.New("limitIp cannot be negative") + } + if input.ExpiryTime < 0 { + return input, errors.New("expiryTime cannot be negative") + } + input.InboundIds = dedupeInboundIDs(input.InboundIds) + return input, nil +} + +func dedupeInboundIDs(ids []int) []int { + set := map[int]bool{} + out := make([]int, 0, len(ids)) + for _, id := range ids { + if id <= 0 || set[id] { + continue + } + set[id] = true + out = append(out, id) + } + sort.Ints(out) + return out +} + +func (s *ClientCenterService) newAssignmentEmail(prefix string, inboundID int) string { + base := strings.TrimSpace(strings.ToLower(prefix)) + if base == "" { + base = "client" + } + return fmt.Sprintf("%s.%s.%s@local", base, strconv.Itoa(inboundID), random.Seq(6)) +} + +func buildProtocolClient(master *model.MasterClient, assignmentEmail string, protocol model.Protocol) (model.Client, string) { + client := model.Client{ + Email: assignmentEmail, + LimitIP: master.LimitIP, + TotalGB: master.TotalGB, + ExpiryTime: master.ExpiryTime, + Enable: master.Enable, + SubID: random.Seq(16), + Comment: master.Comment, + Reset: 0, + } + switch protocol { + case model.Trojan: + client.Password = random.Seq(18) + return client, client.Password + case model.Shadowsocks: + client.Password = random.Seq(18) + return client, client.Email + case model.VMESS: + client.ID = uuid.NewString() + client.Security = "auto" + return client, client.ID + default: // vless and other UUID-based protocols + client.ID = uuid.NewString() + return client, client.ID + } +} + +func (s *ClientCenterService) GetMasterClient(userId, masterID int) (*model.MasterClient, error) { + master := &model.MasterClient{} + err := database.GetDB().Where("id = ? and user_id = ?", masterID, userId).First(master).Error + if err != nil { + return nil, err + } + return master, nil +} + +func (s *ClientCenterService) EnsureTablesReady() error { + // No-op helper to keep service extension points explicit. + return nil +} + +func (s *ClientCenterService) IsNotFound(err error) bool { + return err == gorm.ErrRecordNotFound +}