3x-ui/web/service/client_center.go
2026-02-18 20:11:54 +03:30

459 lines
13 KiB
Go

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
}