mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-02-27 20:53:01 +00:00
feat: add client center for centralized client management
This commit is contained in:
parent
f754246e3e
commit
8b72cfd870
8 changed files with 936 additions and 1 deletions
|
|
@ -36,6 +36,8 @@ func initModels() error {
|
|||
&model.OutboundTraffics{},
|
||||
&model.Setting{},
|
||||
&model.InboundClientIps{},
|
||||
&model.MasterClient{},
|
||||
&model.MasterClientInbound{},
|
||||
&xray.ClientTraffic{},
|
||||
&model.HistoryOfSeeders{},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
127
web/controller/client_center.go
Normal file
127
web/controller/client_center.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
304
web/html/clients.html
Normal 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" .}}
|
||||
|
|
@ -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}}
|
||||
|
|
|
|||
459
web/service/client_center.go
Normal file
459
web/service/client_center.go
Normal 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
|
||||
}
|
||||
Loading…
Reference in a new issue