feat: add client center for centralized client management

This commit is contained in:
Mohamadhosein Moazennia 2026-02-18 20:11:54 +03:30
parent f754246e3e
commit 8b72cfd870
8 changed files with 936 additions and 1 deletions

View file

@ -36,6 +36,8 @@ func initModels() error {
&model.OutboundTraffics{},
&model.Setting{},
&model.InboundClientIps{},
&model.MasterClient{},
&model.MasterClientInbound{},
&xray.ClientTraffic{},
&model.HistoryOfSeeders{},
}

View file

@ -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"`
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)

304
web/html/clients.html Normal file
View file

@ -0,0 +1,304 @@
{{ template "page/head_start" .}}
{{ template "page/head_end" .}}
{{ template "page/body_start" .}}
<a-layout id="app" v-cloak :class="themeSwitcher.currentTheme + ' clients-page'">
<a-sidebar></a-sidebar>
<a-layout id="content-layout">
<a-layout-content>
<a-spin :spinning="loading" :delay="200" tip="Loading...">
<a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]">
<a-col>
<a-card hoverable>
<template #title>
<a-space>
<a-button type="primary" icon="plus" @click="openAdd">Add Master Client</a-button>
<a-button icon="sync" @click="refresh">Refresh</a-button>
</a-space>
</template>
<a-alert
v-if="errorMsg"
type="error"
:message="errorMsg"
show-icon
closable
@close="errorMsg = ''"
:style="{ marginBottom: '12px' }"
></a-alert>
<a-table
:columns="columns"
:data-source="clients"
row-key="id"
:scroll="isMobile ? {} : { x: 1000 }"
:pagination="{ pageSize: 10 }"
>
<template slot="assignments" slot-scope="text, row">
<a-space wrap>
<a-tag v-for="ib in row.assignments" :key="`${row.id}-${ib.id}`" color="blue">
[[ ib.remark ]] ([[ ib.port ]])
</a-tag>
</a-space>
</template>
<template slot="usage" slot-scope="text, row">
[[ SizeFormatter.sizeFormat((row.usageUp || 0) + (row.usageDown || 0)) ]]
</template>
<template slot="expiry" slot-scope="text, row">
[[ formatExpiry(row.expiryTime) ]]
</template>
<template slot="actions" slot-scope="text, row">
<a-space>
<a-button size="small" @click="openEdit(row)">Edit</a-button>
<a-popconfirm
title="Delete this master client and detach all assignments?"
@confirm="remove(row)"
ok-text="Delete"
cancel-text="Cancel"
>
<a-button size="small" type="danger">Delete</a-button>
</a-popconfirm>
</a-space>
</template>
</a-table>
</a-card>
</a-col>
</a-row>
<a-modal
:visible="modal.visible"
:title="modal.editingId ? 'Edit Master Client' : 'Add Master Client'"
@ok="submit"
@cancel="closeModal"
ok-text="Save"
>
<a-form layout="vertical">
<a-form-item label="Display Name">
<a-input v-model.trim="form.name" placeholder="e.g. Team-A Premium"></a-input>
</a-form-item>
<a-form-item label="Email Prefix (for generated assignment emails)">
<a-input v-model.trim="form.emailPrefix" placeholder="e.g. teama"></a-input>
</a-form-item>
<a-row :gutter="12">
<a-col :span="12">
<a-form-item label="Traffic Quota (GB)">
<a-input-number v-model="form.totalGB" :min="0" :style="{ width: '100%' }"></a-input-number>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="IP Limit">
<a-input-number v-model="form.limitIp" :min="0" :style="{ width: '100%' }"></a-input-number>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="Expiry Date">
<a-date-picker
show-time
:style="{ width: '100%' }"
:value="form.expiryMoment"
@change="onExpiryChange"
format="YYYY-MM-DD HH:mm"
></a-date-picker>
</a-form-item>
<a-form-item label="Assigned Inbounds">
<a-select
mode="multiple"
v-model="form.inboundIds"
:style="{ width: '100%' }"
placeholder="Select inbounds"
>
<a-select-option v-for="ib in availableInbounds" :key="ib.id" :value="ib.id">
[[ ib.remark ]] (port [[ ib.port ]], [[ ib.protocol ]])
</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-switch v-model="form.enable"></a-switch>
<span :style="{ marginLeft: '8px' }">Enabled</span>
</a-form-item>
<a-form-item label="Comment">
<a-input v-model.trim="form.comment" placeholder="Optional note"></a-input>
</a-form-item>
</a-form>
</a-modal>
</a-spin>
</a-layout-content>
</a-layout>
</a-layout>
{{template "page/body_scripts" .}}
{{template "component/aSidebar" .}}
{{template "component/aThemeSwitch" .}}
<script>
const app = new Vue({
delimiters: ['[[', ']]'],
mixins: [MediaQueryMixin],
el: '#app',
data: {
themeSwitcher,
loading: false,
errorMsg: '',
clients: [],
availableInbounds: [],
columns: [
{ title: 'Name', dataIndex: 'name', key: 'name' },
{ title: 'Prefix', dataIndex: 'emailPrefix', key: 'emailPrefix' },
{ title: 'Quota', dataIndex: 'totalGB', key: 'totalGB', customRender: (v) => `${v} GB` },
{ title: 'IP Limit', dataIndex: 'limitIp', key: 'limitIp' },
{ title: 'Expiry', key: 'expiry', scopedSlots: { customRender: 'expiry' } },
{ title: 'Assigned Inbounds', key: 'assignments', scopedSlots: { customRender: 'assignments' } },
{ title: 'Usage', key: 'usage', scopedSlots: { customRender: 'usage' } },
{
title: 'Enabled',
dataIndex: 'enable',
key: 'enable',
customRender: (v) => v ? 'Yes' : 'No'
},
{ title: 'Actions', key: 'actions', scopedSlots: { customRender: 'actions' } },
],
modal: {
visible: false,
editingId: 0,
},
form: {
name: '',
emailPrefix: '',
totalGB: 0,
expiryTime: 0,
expiryMoment: null,
limitIp: 0,
enable: true,
comment: '',
inboundIds: [],
},
},
methods: {
formatExpiry(ts) {
if (!ts || ts <= 0) return 'Never';
return moment(ts).format('YYYY-MM-DD HH:mm');
},
onExpiryChange(value) {
this.form.expiryMoment = value;
this.form.expiryTime = value ? value.valueOf() : 0;
},
resetForm() {
this.form = {
name: '',
emailPrefix: '',
totalGB: 0,
expiryTime: 0,
expiryMoment: null,
limitIp: 0,
enable: true,
comment: '',
inboundIds: [],
};
},
closeModal() {
this.modal.visible = false;
this.modal.editingId = 0;
this.resetForm();
},
openAdd() {
this.resetForm();
this.modal.editingId = 0;
this.modal.visible = true;
},
openEdit(row) {
this.form = {
name: row.name,
emailPrefix: row.emailPrefix,
totalGB: row.totalGB,
expiryTime: row.expiryTime || 0,
expiryMoment: row.expiryTime > 0 ? moment(row.expiryTime) : null,
limitIp: row.limitIp,
enable: !!row.enable,
comment: row.comment || '',
inboundIds: (row.assignments || []).map(a => a.id),
};
this.modal.editingId = row.id;
this.modal.visible = true;
},
async fetchInbounds() {
const res = await axios.get('panel/api/clients/inbounds');
if (!res.data.success) {
throw new Error(res.data.msg || 'Failed to fetch inbounds');
}
this.availableInbounds = res.data.obj || [];
},
async fetchClients() {
const res = await axios.get('panel/api/clients/list');
if (!res.data.success) {
throw new Error(res.data.msg || 'Failed to fetch clients');
}
this.clients = res.data.obj || [];
},
async refresh() {
this.loading = true;
this.errorMsg = '';
try {
await Promise.all([this.fetchInbounds(), this.fetchClients()]);
} catch (e) {
this.errorMsg = e.message || String(e);
} finally {
this.loading = false;
}
},
async submit() {
if (!this.form.name) {
return app.$message.error('Name is required');
}
if (!this.form.emailPrefix) {
return app.$message.error('Email prefix is required');
}
const payload = {
name: this.form.name,
emailPrefix: this.form.emailPrefix,
totalGB: this.form.totalGB || 0,
expiryTime: this.form.expiryTime || 0,
limitIp: this.form.limitIp || 0,
enable: !!this.form.enable,
comment: this.form.comment || '',
inboundIds: this.form.inboundIds || [],
};
this.loading = true;
this.errorMsg = '';
try {
let res;
if (this.modal.editingId) {
res = await axios.post(`panel/api/clients/update/${this.modal.editingId}`, payload);
} else {
res = await axios.post('panel/api/clients/add', payload);
}
if (!res.data.success) {
throw new Error(res.data.msg || 'Save failed');
}
app.$message.success(res.data.msg || 'Saved');
this.closeModal();
await this.refresh();
} catch (e) {
this.errorMsg = e.message || String(e);
} finally {
this.loading = false;
}
},
async remove(row) {
this.loading = true;
this.errorMsg = '';
try {
const res = await axios.post(`panel/api/clients/del/${row.id}`);
if (!res.data.success) {
throw new Error(res.data.msg || 'Delete failed');
}
app.$message.success(res.data.msg || 'Deleted');
await this.refresh();
} catch (e) {
this.errorMsg = e.message || String(e);
} finally {
this.loading = false;
}
},
},
async mounted() {
await this.refresh();
},
});
</script>
{{template "page/body_end" .}}

View file

@ -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"}}`,
});
</script>
{{end}}
{{end}}

View file

@ -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
}