mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-02-28 13:13:00 +00:00
459 lines
13 KiB
Go
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
|
|
}
|