mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-04-25 08:55:59 +00:00
Feature: Copy clients between inbounds (#4087)
Some checks are pending
Release 3X-UI / Analyze Go code (push) Waiting to run
Release 3X-UI / build (386) (push) Blocked by required conditions
Release 3X-UI / build (amd64) (push) Blocked by required conditions
Release 3X-UI / build (arm64) (push) Blocked by required conditions
Release 3X-UI / build (armv5) (push) Blocked by required conditions
Release 3X-UI / build (armv6) (push) Blocked by required conditions
Release 3X-UI / build (armv7) (push) Blocked by required conditions
Release 3X-UI / build (s390x) (push) Blocked by required conditions
Release 3X-UI / Build for Windows (push) Blocked by required conditions
Some checks are pending
Release 3X-UI / Analyze Go code (push) Waiting to run
Release 3X-UI / build (386) (push) Blocked by required conditions
Release 3X-UI / build (amd64) (push) Blocked by required conditions
Release 3X-UI / build (arm64) (push) Blocked by required conditions
Release 3X-UI / build (armv5) (push) Blocked by required conditions
Release 3X-UI / build (armv6) (push) Blocked by required conditions
Release 3X-UI / build (armv7) (push) Blocked by required conditions
Release 3X-UI / build (s390x) (push) Blocked by required conditions
Release 3X-UI / Build for Windows (push) Blocked by required conditions
* feat: copy clients between inbounds * fix: copy clients modal not opening * fix: copy clients modal not opening * fix: copy clients modal not opening * fix: copy clients modal not opening * fix: copy clients modal not opening * fix: copy clients modal not opening * fix: copy clients modal not opening * fix: copy clients modal not opening * fix: copy clients modal not opening * revert: undo install.sh/deploy.sh changes; i18n: add copy-clients translations for all languages --------- Co-authored-by: Нестеров Руслан <r.nesterov@comagic.dev>
This commit is contained in:
parent
ff25072690
commit
6bcaf61c44
16 changed files with 641 additions and 0 deletions
|
|
@ -41,6 +41,7 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) {
|
|||
g.POST("/clientIps/:email", a.getClientIps)
|
||||
g.POST("/clearClientIps/:email", a.clearClientIps)
|
||||
g.POST("/addClient", a.addInboundClient)
|
||||
g.POST("/:id/copyClients", a.copyInboundClients)
|
||||
g.POST("/:id/delClient/:clientId", a.delInboundClient)
|
||||
g.POST("/updateClient/:clientId", a.updateInboundClient)
|
||||
g.POST("/:id/resetClientTraffic/:email", a.resetClientTraffic)
|
||||
|
|
@ -54,6 +55,12 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) {
|
|||
g.POST("/:id/delClientByEmail/:email", a.delInboundClientByEmail)
|
||||
}
|
||||
|
||||
type CopyInboundClientsRequest struct {
|
||||
SourceInboundID int `form:"sourceInboundId" json:"sourceInboundId"`
|
||||
ClientEmails []string `form:"clientEmails" json:"clientEmails"`
|
||||
Flow string `form:"flow" json:"flow"`
|
||||
}
|
||||
|
||||
// getInbounds retrieves the list of inbounds for the logged-in user.
|
||||
func (a *InboundController) getInbounds(c *gin.Context) {
|
||||
user := session.GetLoginUser(c)
|
||||
|
|
@ -260,6 +267,36 @@ func (a *InboundController) addInboundClient(c *gin.Context) {
|
|||
}
|
||||
}
|
||||
|
||||
// copyInboundClients copies clients from source inbound to target inbound.
|
||||
func (a *InboundController) copyInboundClients(c *gin.Context) {
|
||||
targetID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
|
||||
req := &CopyInboundClientsRequest{}
|
||||
err = c.ShouldBind(req)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
if req.SourceInboundID <= 0 {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), fmt.Errorf("invalid source inbound id"))
|
||||
return
|
||||
}
|
||||
|
||||
result, needRestart, err := a.inboundService.CopyInboundClients(targetID, req.SourceInboundID, req.ClientEmails, req.Flow)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, result, nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
}
|
||||
|
||||
// delInboundClient deletes a client from an inbound by inbound ID and client ID.
|
||||
func (a *InboundController) delInboundClient(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
|
|
|
|||
|
|
@ -262,6 +262,10 @@
|
|||
<a-icon type="usergroup-add"></a-icon>
|
||||
{{ i18n "pages.client.bulk"}}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="copyClients">
|
||||
<a-icon type="copy"></a-icon>
|
||||
{{ i18n "pages.client.copyFromInbound"}}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="resetClients">
|
||||
<a-icon type="file-done"></a-icon>
|
||||
{{ i18n
|
||||
|
|
@ -777,6 +781,218 @@
|
|||
{{template "modals/inboundInfoModal"}}
|
||||
{{template "modals/clientsModal"}}
|
||||
{{template "modals/clientsBulkModal"}}
|
||||
<a-modal id="copy-clients-modal"
|
||||
:title="copyClientsModal.title"
|
||||
:visible="copyClientsModal.visible"
|
||||
:confirm-loading="copyClientsModal.confirmLoading"
|
||||
ok-text='{{ i18n "pages.client.copySelected" }}'
|
||||
cancel-text='{{ i18n "close" }}'
|
||||
:class="themeSwitcher.currentTheme"
|
||||
:closable="true"
|
||||
:mask-closable="false"
|
||||
@ok="() => copyClientsModal.ok()"
|
||||
@cancel="() => copyClientsModal.close()"
|
||||
width="900px">
|
||||
<a-space direction="vertical" style="width: 100%;">
|
||||
<div>
|
||||
<div style="margin-bottom: 6px;">{{ i18n "pages.client.copySource" }}</div>
|
||||
<a-select v-model="copyClientsModal.sourceInboundId"
|
||||
style="width: 100%;"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
@change="id => copyClientsModal.onSourceChange(id)">
|
||||
<a-select-option v-for="item in copyClientsModal.sources"
|
||||
:key="item.id"
|
||||
:value="item.id">
|
||||
[[ item.label ]]
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
<div v-if="copyClientsModal.sourceInboundId">
|
||||
<a-space style="margin-bottom: 10px;">
|
||||
<a-button size="small" @click="() => copyClientsModal.selectAll()">{{ i18n "pages.client.selectAll" }}</a-button>
|
||||
<a-button size="small" @click="() => copyClientsModal.clearAll()">{{ i18n "pages.client.clearAll" }}</a-button>
|
||||
</a-space>
|
||||
<a-table :columns="copyClientsColumns"
|
||||
:data-source="copyClientsModal.sourceClients"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
:row-key="item => item.email"
|
||||
:scroll="{ y: 280 }">
|
||||
<template slot="emailCheckbox" slot-scope="text, record">
|
||||
<a-checkbox :checked="copyClientsModal.selectedEmails.includes(record.email)"
|
||||
@change="event => copyClientsModal.toggleEmail(record.email, event.target.checked)">
|
||||
[[ record.email ]]
|
||||
</a-checkbox>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
<div v-if="copyClientsModal.showFlow">
|
||||
<div style="margin-bottom: 6px;">{{ i18n "pages.client.copyFlowLabel" }}</div>
|
||||
<a-select v-model="copyClientsModal.flow"
|
||||
style="width: 100%;"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme"
|
||||
allow-clear>
|
||||
<a-select-option value="">{{ i18n "none" }}</a-select-option>
|
||||
<a-select-option value="xtls-rprx-vision">xtls-rprx-vision</a-select-option>
|
||||
<a-select-option value="xtls-rprx-vision-udp443">xtls-rprx-vision-udp443</a-select-option>
|
||||
</a-select>
|
||||
<div style="margin-top: 4px; font-size: 12px; opacity: 0.7;">
|
||||
{{ i18n "pages.client.copyFlowHint" }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="copyClientsModal.selectedEmails.length > 0">
|
||||
<div style="margin-bottom: 4px;">{{ i18n "pages.client.copyEmailPreview" }}</div>
|
||||
<div style="max-height: 120px; overflow-y: auto;">
|
||||
<a-tag v-for="preview in previewEmails" :key="preview" style="margin-bottom: 4px;">
|
||||
[[ preview ]]
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</a-space>
|
||||
</a-modal>
|
||||
<script>
|
||||
const copyClientsColumns = [
|
||||
{ title: '{{ i18n "pages.inbounds.email" }}', width: 300, scopedSlots: { customRender: 'emailCheckbox' } },
|
||||
{ title: '{{ i18n "pages.inbounds.traffic" }}', width: 160, dataIndex: 'trafficLabel' },
|
||||
{ title: '{{ i18n "pages.inbounds.expireDate" }}', width: 180, dataIndex: 'expiryLabel' },
|
||||
];
|
||||
|
||||
const copyClientsModal = {
|
||||
visible: false,
|
||||
confirmLoading: false,
|
||||
title: '',
|
||||
targetInboundId: 0,
|
||||
targetInboundRemark: '',
|
||||
targetProtocol: '',
|
||||
showFlow: false,
|
||||
flow: '',
|
||||
sourceInboundId: undefined,
|
||||
sources: [],
|
||||
sourceClients: [],
|
||||
selectedEmails: [],
|
||||
show(targetDbInbound) {
|
||||
if (!targetDbInbound) return;
|
||||
const sources = app.dbInbounds
|
||||
.filter(row => row.id !== targetDbInbound.id && typeof row.isMultiUser === 'function' && row.isMultiUser())
|
||||
.map(row => {
|
||||
const clients = app.getInboundClients(row) || [];
|
||||
return { id: row.id, label: `${row.remark} (${row.protocol}, ${clients.length})` };
|
||||
});
|
||||
let showFlow = false;
|
||||
try {
|
||||
const targetInbound = targetDbInbound.toInbound();
|
||||
showFlow = !!(targetInbound && typeof targetInbound.canEnableTlsFlow === 'function' && targetInbound.canEnableTlsFlow());
|
||||
} catch (e) {
|
||||
showFlow = false;
|
||||
}
|
||||
copyClientsModal.targetInboundId = targetDbInbound.id;
|
||||
copyClientsModal.targetInboundRemark = targetDbInbound.remark;
|
||||
copyClientsModal.targetProtocol = targetDbInbound.protocol;
|
||||
copyClientsModal.showFlow = showFlow;
|
||||
copyClientsModal.flow = '';
|
||||
copyClientsModal.title = `{{ i18n "pages.client.copyToInbound" }} ${targetDbInbound.remark}`;
|
||||
copyClientsModal.sources = sources;
|
||||
copyClientsModal.sourceInboundId = undefined;
|
||||
copyClientsModal.sourceClients = [];
|
||||
copyClientsModal.selectedEmails = [];
|
||||
copyClientsModal.confirmLoading = false;
|
||||
copyClientsModal.visible = true;
|
||||
},
|
||||
close() {
|
||||
copyClientsModal.visible = false;
|
||||
copyClientsModal.confirmLoading = false;
|
||||
},
|
||||
onSourceChange(sourceInboundId) {
|
||||
copyClientsModal.selectedEmails = [];
|
||||
const sourceInbound = app.dbInbounds.find(row => row.id === Number(sourceInboundId));
|
||||
if (!sourceInbound) {
|
||||
copyClientsModal.sourceClients = [];
|
||||
return;
|
||||
}
|
||||
const sourceClients = app.getInboundClients(sourceInbound) || [];
|
||||
copyClientsModal.sourceClients = sourceClients.map(client => {
|
||||
const stats = app.getClientStats(sourceInbound, client.email);
|
||||
const used = stats ? ((stats.up || 0) + (stats.down || 0)) : 0;
|
||||
let expiryLabel = '{{ i18n "unlimited" }}';
|
||||
if (client.expiryTime > 0) {
|
||||
expiryLabel = IntlUtil.formatDate(client.expiryTime);
|
||||
} else if (client.expiryTime < 0) {
|
||||
expiryLabel = `${-client.expiryTime / 86400000}d`;
|
||||
}
|
||||
return {
|
||||
email: client.email,
|
||||
trafficLabel: SizeFormatter.sizeFormat(used),
|
||||
expiryLabel,
|
||||
};
|
||||
});
|
||||
},
|
||||
toggleEmail(email, checked) {
|
||||
const selected = copyClientsModal.selectedEmails.slice();
|
||||
if (checked) {
|
||||
if (!selected.includes(email)) selected.push(email);
|
||||
} else {
|
||||
const idx = selected.indexOf(email);
|
||||
if (idx >= 0) selected.splice(idx, 1);
|
||||
}
|
||||
copyClientsModal.selectedEmails = selected;
|
||||
},
|
||||
selectAll() {
|
||||
copyClientsModal.selectedEmails = copyClientsModal.sourceClients.map(item => item.email);
|
||||
},
|
||||
clearAll() {
|
||||
copyClientsModal.selectedEmails = [];
|
||||
},
|
||||
async ok() {
|
||||
if (!copyClientsModal.sourceInboundId) {
|
||||
app.$message.error('{{ i18n "pages.client.copySelectSourceFirst" }}');
|
||||
return;
|
||||
}
|
||||
copyClientsModal.confirmLoading = true;
|
||||
const payload = {
|
||||
sourceInboundId: copyClientsModal.sourceInboundId,
|
||||
clientEmails: copyClientsModal.selectedEmails,
|
||||
};
|
||||
if (copyClientsModal.showFlow && copyClientsModal.flow) {
|
||||
payload.flow = copyClientsModal.flow;
|
||||
}
|
||||
try {
|
||||
const msg = await HttpUtil.post(`/panel/api/inbounds/${copyClientsModal.targetInboundId}/copyClients`, payload);
|
||||
if (!msg || !msg.success) return;
|
||||
const obj = msg.obj || {};
|
||||
const addedCount = (obj.added || []).length;
|
||||
const errorList = obj.errors || [];
|
||||
if (addedCount > 0) {
|
||||
app.$message.success(`{{ i18n "pages.client.copyResultSuccess" }}: ${addedCount}`);
|
||||
} else {
|
||||
app.$message.warning('{{ i18n "pages.client.copyResultNone" }}');
|
||||
}
|
||||
if (errorList.length > 0) {
|
||||
app.$message.error(`{{ i18n "pages.client.copyResultErrors" }}: ${errorList.join('; ')}`);
|
||||
}
|
||||
copyClientsModal.close();
|
||||
await app.getDBInbounds();
|
||||
} finally {
|
||||
copyClientsModal.confirmLoading = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const copyClientsModalApp = new Vue({
|
||||
delimiters: ['[[', ']]'],
|
||||
el: '#copy-clients-modal',
|
||||
data: {
|
||||
copyClientsModal,
|
||||
copyClientsColumns,
|
||||
themeSwitcher,
|
||||
},
|
||||
computed: {
|
||||
previewEmails() {
|
||||
if (!this.copyClientsModal.targetInboundId) return [];
|
||||
return this.copyClientsModal.selectedEmails.map(email => `${email}_${this.copyClientsModal.targetInboundId}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<script>
|
||||
const columns = [{
|
||||
title: "ID",
|
||||
|
|
@ -1135,6 +1351,9 @@
|
|||
case "addBulkClient":
|
||||
this.openAddBulkClient(dbInbound.id)
|
||||
break;
|
||||
case "copyClients":
|
||||
copyClientsModal.show(dbInbound);
|
||||
break;
|
||||
case "export":
|
||||
this.inboundLinks(dbInbound.id);
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"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/logger"
|
||||
|
|
@ -26,6 +27,12 @@ type InboundService struct {
|
|||
xrayApi xray.XrayAPI
|
||||
}
|
||||
|
||||
type CopyClientsResult struct {
|
||||
Added []string `json:"added"`
|
||||
Skipped []string `json:"skipped"`
|
||||
Errors []string `json:"errors"`
|
||||
}
|
||||
|
||||
// GetInbounds retrieves all inbounds for a specific user.
|
||||
// Returns a slice of inbound models with their associated client statistics.
|
||||
func (s *InboundService) GetInbounds(userId int) ([]*model.Inbound, error) {
|
||||
|
|
@ -750,6 +757,202 @@ func (s *InboundService) AddInboundClient(data *model.Inbound) (bool, error) {
|
|||
return needRestart, tx.Save(oldInbound).Error
|
||||
}
|
||||
|
||||
func (s *InboundService) getClientPrimaryKey(protocol model.Protocol, client model.Client) string {
|
||||
switch protocol {
|
||||
case model.Trojan:
|
||||
return client.Password
|
||||
case model.Shadowsocks:
|
||||
return client.Email
|
||||
case model.Hysteria:
|
||||
return client.Auth
|
||||
default:
|
||||
return client.ID
|
||||
}
|
||||
}
|
||||
|
||||
func (s *InboundService) writeBackClientSubID(sourceInboundID int, sourceProtocol model.Protocol, client model.Client, subID string) (bool, error) {
|
||||
client.SubID = subID
|
||||
client.UpdatedAt = time.Now().UnixMilli()
|
||||
clientID := s.getClientPrimaryKey(sourceProtocol, client)
|
||||
if clientID == "" {
|
||||
return false, common.NewError("empty client ID")
|
||||
}
|
||||
|
||||
settingsBytes, err := json.Marshal(map[string][]model.Client{
|
||||
"clients": []model.Client{client},
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
updatePayload := &model.Inbound{
|
||||
Id: sourceInboundID,
|
||||
Settings: string(settingsBytes),
|
||||
}
|
||||
return s.UpdateInboundClient(updatePayload, clientID)
|
||||
}
|
||||
|
||||
func (s *InboundService) generateRandomCredential(targetProtocol model.Protocol) string {
|
||||
switch targetProtocol {
|
||||
case model.VMESS, model.VLESS:
|
||||
return uuid.NewString()
|
||||
default:
|
||||
return strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *InboundService) buildTargetClientFromSource(source model.Client, targetProtocol model.Protocol, email string, flow string) (model.Client, error) {
|
||||
nowTs := time.Now().UnixMilli()
|
||||
target := source
|
||||
target.Email = email
|
||||
target.CreatedAt = nowTs
|
||||
target.UpdatedAt = nowTs
|
||||
|
||||
target.ID = ""
|
||||
target.Password = ""
|
||||
target.Auth = ""
|
||||
target.Flow = ""
|
||||
|
||||
switch targetProtocol {
|
||||
case model.VMESS:
|
||||
target.ID = s.generateRandomCredential(targetProtocol)
|
||||
case model.VLESS:
|
||||
target.ID = s.generateRandomCredential(targetProtocol)
|
||||
if flow == "xtls-rprx-vision" || flow == "xtls-rprx-vision-udp443" {
|
||||
target.Flow = flow
|
||||
}
|
||||
case model.Trojan, model.Shadowsocks:
|
||||
target.Password = s.generateRandomCredential(targetProtocol)
|
||||
case model.Hysteria:
|
||||
target.Auth = s.generateRandomCredential(targetProtocol)
|
||||
default:
|
||||
target.ID = s.generateRandomCredential(targetProtocol)
|
||||
}
|
||||
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func (s *InboundService) nextAvailableCopiedEmail(originalEmail string, targetID int, occupied map[string]struct{}) string {
|
||||
base := fmt.Sprintf("%s_%d", originalEmail, targetID)
|
||||
candidate := base
|
||||
suffix := 0
|
||||
for {
|
||||
if _, exists := occupied[strings.ToLower(candidate)]; !exists {
|
||||
occupied[strings.ToLower(candidate)] = struct{}{}
|
||||
return candidate
|
||||
}
|
||||
suffix++
|
||||
candidate = fmt.Sprintf("%s_%d", base, suffix)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *InboundService) CopyInboundClients(targetInboundID int, sourceInboundID int, clientEmails []string, flow string) (*CopyClientsResult, bool, error) {
|
||||
result := &CopyClientsResult{
|
||||
Added: []string{},
|
||||
Skipped: []string{},
|
||||
Errors: []string{},
|
||||
}
|
||||
if targetInboundID == sourceInboundID {
|
||||
return result, false, common.NewError("source and target inbounds must be different")
|
||||
}
|
||||
|
||||
targetInbound, err := s.GetInbound(targetInboundID)
|
||||
if err != nil {
|
||||
return result, false, err
|
||||
}
|
||||
sourceInbound, err := s.GetInbound(sourceInboundID)
|
||||
if err != nil {
|
||||
return result, false, err
|
||||
}
|
||||
|
||||
sourceClients, err := s.GetClients(sourceInbound)
|
||||
if err != nil {
|
||||
return result, false, err
|
||||
}
|
||||
if len(sourceClients) == 0 {
|
||||
return result, false, nil
|
||||
}
|
||||
|
||||
allowedEmails := map[string]struct{}{}
|
||||
if len(clientEmails) > 0 {
|
||||
for _, email := range clientEmails {
|
||||
allowedEmails[strings.ToLower(strings.TrimSpace(email))] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
occupiedEmails := map[string]struct{}{}
|
||||
allEmails, err := s.getAllEmails()
|
||||
if err != nil {
|
||||
return result, false, err
|
||||
}
|
||||
for _, email := range allEmails {
|
||||
clean := strings.Trim(email, "\"")
|
||||
if clean != "" {
|
||||
occupiedEmails[strings.ToLower(clean)] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
newClients := make([]model.Client, 0)
|
||||
needRestart := false
|
||||
for _, sourceClient := range sourceClients {
|
||||
originalEmail := strings.TrimSpace(sourceClient.Email)
|
||||
if originalEmail == "" {
|
||||
continue
|
||||
}
|
||||
if len(allowedEmails) > 0 {
|
||||
if _, ok := allowedEmails[strings.ToLower(originalEmail)]; !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if sourceClient.SubID == "" {
|
||||
newSubID := uuid.NewString()
|
||||
subNeedRestart, subErr := s.writeBackClientSubID(sourceInbound.Id, sourceInbound.Protocol, sourceClient, newSubID)
|
||||
if subErr != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("%s: failed to write source subId: %v", originalEmail, subErr))
|
||||
continue
|
||||
}
|
||||
if subNeedRestart {
|
||||
needRestart = true
|
||||
}
|
||||
sourceClient.SubID = newSubID
|
||||
}
|
||||
|
||||
targetEmail := s.nextAvailableCopiedEmail(originalEmail, targetInboundID, occupiedEmails)
|
||||
targetClient, buildErr := s.buildTargetClientFromSource(sourceClient, targetInbound.Protocol, targetEmail, flow)
|
||||
if buildErr != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", originalEmail, buildErr))
|
||||
continue
|
||||
}
|
||||
newClients = append(newClients, targetClient)
|
||||
result.Added = append(result.Added, targetEmail)
|
||||
}
|
||||
|
||||
if len(newClients) == 0 {
|
||||
return result, needRestart, nil
|
||||
}
|
||||
|
||||
settingsPayload, err := json.Marshal(map[string][]model.Client{
|
||||
"clients": newClients,
|
||||
})
|
||||
if err != nil {
|
||||
return result, needRestart, err
|
||||
}
|
||||
|
||||
addNeedRestart, err := s.AddInboundClient(&model.Inbound{
|
||||
Id: targetInboundID,
|
||||
Settings: string(settingsPayload),
|
||||
})
|
||||
if err != nil {
|
||||
return result, needRestart, err
|
||||
}
|
||||
if addNeedRestart {
|
||||
needRestart = true
|
||||
}
|
||||
|
||||
return result, needRestart, nil
|
||||
}
|
||||
|
||||
func (s *InboundService) DelInboundClient(inboundId int, clientId string) (bool, error) {
|
||||
oldInbound, err := s.GetInbound(inboundId)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -298,6 +298,20 @@
|
|||
"submitEdit" = "احفظ التعديلات"
|
||||
"clientCount" = "عدد العملاء"
|
||||
"bulk" = "إضافة بالجملة"
|
||||
"copyFromInbound" = "نسخ العملاء من الـ Inbound"
|
||||
"copyToInbound" = "نسخ العملاء إلى"
|
||||
"copySelected" = "نسخ المحدد"
|
||||
"copySource" = "المصدر"
|
||||
"copyEmailPreview" = "معاينة البريد الإلكتروني الناتج"
|
||||
"copySelectSourceFirst" = "الرجاء اختيار الـ Inbound المصدر أولاً."
|
||||
"copyResult" = "نتيجة النسخ"
|
||||
"copyResultSuccess" = "تم النسخ بنجاح"
|
||||
"copyResultNone" = "لا يوجد شيء للنسخ: لم يتم اختيار أي عميل أو أن المصدر فارغ"
|
||||
"copyResultErrors" = "أخطاء النسخ"
|
||||
"copyFlowLabel" = "Flow للعملاء الجدد (VLESS)"
|
||||
"copyFlowHint" = "يُطبَّق على جميع العملاء المنسوخين. اتركه فارغاً لتخطيه."
|
||||
"selectAll" = "تحديد الكل"
|
||||
"clearAll" = "مسح الكل"
|
||||
"method" = "طريقة"
|
||||
"first" = "أول واحد"
|
||||
"last" = "آخر واحد"
|
||||
|
|
|
|||
|
|
@ -298,6 +298,20 @@
|
|||
"submitEdit" = "Save Changes"
|
||||
"clientCount" = "Number of Clients"
|
||||
"bulk" = "Add Bulk"
|
||||
"copyFromInbound" = "Copy Clients from Inbound"
|
||||
"copyToInbound" = "Copy clients to"
|
||||
"copySelected" = "Copy Selected"
|
||||
"copySource" = "Source"
|
||||
"copyEmailPreview" = "Resulting email preview"
|
||||
"copySelectSourceFirst" = "Please select a source inbound first."
|
||||
"copyResult" = "Copy result"
|
||||
"copyResultSuccess" = "Copied successfully"
|
||||
"copyResultNone" = "Nothing to copy: no clients selected or source is empty"
|
||||
"copyResultErrors" = "Copy errors"
|
||||
"copyFlowLabel" = "Flow for new clients (VLESS)"
|
||||
"copyFlowHint" = "Applied to all copied clients. Leave empty to skip."
|
||||
"selectAll" = "Select all"
|
||||
"clearAll" = "Clear all"
|
||||
"method" = "Method"
|
||||
"first" = "First"
|
||||
"last" = "Last"
|
||||
|
|
|
|||
|
|
@ -298,6 +298,20 @@
|
|||
"submitEdit" = "Guardar Cambios"
|
||||
"clientCount" = "Número de Clientes"
|
||||
"bulk" = "Agregar en Lote"
|
||||
"copyFromInbound" = "Copiar clientes desde entrada"
|
||||
"copyToInbound" = "Copiar clientes a"
|
||||
"copySelected" = "Copiar seleccionados"
|
||||
"copySource" = "Origen"
|
||||
"copyEmailPreview" = "Vista previa del email resultante"
|
||||
"copySelectSourceFirst" = "Seleccione primero una entrada de origen."
|
||||
"copyResult" = "Resultado de la copia"
|
||||
"copyResultSuccess" = "Copiado correctamente"
|
||||
"copyResultNone" = "Nada que copiar: ningún cliente seleccionado o el origen está vacío"
|
||||
"copyResultErrors" = "Errores al copiar"
|
||||
"copyFlowLabel" = "Flow para nuevos clientes (VLESS)"
|
||||
"copyFlowHint" = "Se aplica a todos los clientes copiados. Déjelo vacío para omitir."
|
||||
"selectAll" = "Seleccionar todo"
|
||||
"clearAll" = "Limpiar todo"
|
||||
"method" = "Método"
|
||||
"first" = "Primero"
|
||||
"last" = "Último"
|
||||
|
|
|
|||
|
|
@ -298,6 +298,20 @@
|
|||
"submitEdit" = "ذخیره تغییرات"
|
||||
"clientCount" = "تعداد کاربران"
|
||||
"bulk" = "انبوهسازی"
|
||||
"copyFromInbound" = "کپی کاربران از اینباند"
|
||||
"copyToInbound" = "کپی کاربران به"
|
||||
"copySelected" = "کپی انتخابشدهها"
|
||||
"copySource" = "منبع"
|
||||
"copyEmailPreview" = "پیشنمایش ایمیل نهایی"
|
||||
"copySelectSourceFirst" = "ابتدا یک اینباند منبع انتخاب کنید."
|
||||
"copyResult" = "نتیجه کپی"
|
||||
"copyResultSuccess" = "با موفقیت کپی شد"
|
||||
"copyResultNone" = "چیزی برای کپی نیست: هیچ کاربری انتخاب نشده یا منبع خالی است"
|
||||
"copyResultErrors" = "خطاهای کپی"
|
||||
"copyFlowLabel" = "Flow برای کاربران جدید (VLESS)"
|
||||
"copyFlowHint" = "برای همه کاربران کپیشده اعمال میشود. برای نادیده گرفتن، خالی بگذارید."
|
||||
"selectAll" = "انتخاب همه"
|
||||
"clearAll" = "پاک کردن همه"
|
||||
"method" = "روش"
|
||||
"first" = "از"
|
||||
"last" = "تا"
|
||||
|
|
|
|||
|
|
@ -298,6 +298,20 @@
|
|||
"submitEdit" = "Simpan Perubahan"
|
||||
"clientCount" = "Jumlah Klien"
|
||||
"bulk" = "Tambahkan Massal"
|
||||
"copyFromInbound" = "Salin klien dari inbound"
|
||||
"copyToInbound" = "Salin klien ke"
|
||||
"copySelected" = "Salin yang dipilih"
|
||||
"copySource" = "Sumber"
|
||||
"copyEmailPreview" = "Pratinjau email hasil"
|
||||
"copySelectSourceFirst" = "Silakan pilih inbound sumber terlebih dahulu."
|
||||
"copyResult" = "Hasil penyalinan"
|
||||
"copyResultSuccess" = "Berhasil disalin"
|
||||
"copyResultNone" = "Tidak ada yang disalin: tidak ada klien yang dipilih atau sumber kosong"
|
||||
"copyResultErrors" = "Kesalahan penyalinan"
|
||||
"copyFlowLabel" = "Flow untuk klien baru (VLESS)"
|
||||
"copyFlowHint" = "Diterapkan ke semua klien yang disalin. Biarkan kosong untuk melewati."
|
||||
"selectAll" = "Pilih semua"
|
||||
"clearAll" = "Hapus semua"
|
||||
"method" = "Metode"
|
||||
"first" = "Pertama"
|
||||
"last" = "Terakhir"
|
||||
|
|
|
|||
|
|
@ -298,6 +298,20 @@
|
|||
"submitEdit" = "変更を保存"
|
||||
"clientCount" = "クライアント数"
|
||||
"bulk" = "一括作成"
|
||||
"copyFromInbound" = "インバウンドからクライアントをコピー"
|
||||
"copyToInbound" = "クライアントのコピー先"
|
||||
"copySelected" = "選択項目をコピー"
|
||||
"copySource" = "ソース"
|
||||
"copyEmailPreview" = "結果メールのプレビュー"
|
||||
"copySelectSourceFirst" = "先にソースインバウンドを選択してください。"
|
||||
"copyResult" = "コピー結果"
|
||||
"copyResultSuccess" = "正常にコピーされました"
|
||||
"copyResultNone" = "コピーする項目がありません: クライアントが選択されていないかソースが空です"
|
||||
"copyResultErrors" = "コピーエラー"
|
||||
"copyFlowLabel" = "新規クライアントの Flow (VLESS)"
|
||||
"copyFlowHint" = "すべてのコピー対象クライアントに適用されます。空のままにするとスキップします。"
|
||||
"selectAll" = "すべて選択"
|
||||
"clearAll" = "すべて解除"
|
||||
"method" = "方法"
|
||||
"first" = "最初"
|
||||
"last" = "最後"
|
||||
|
|
|
|||
|
|
@ -298,6 +298,20 @@
|
|||
"submitEdit" = "Salvar Alterações"
|
||||
"clientCount" = "Número de Clientes"
|
||||
"bulk" = "Adicionar Vários"
|
||||
"copyFromInbound" = "Copiar clientes da entrada"
|
||||
"copyToInbound" = "Copiar clientes para"
|
||||
"copySelected" = "Copiar selecionados"
|
||||
"copySource" = "Origem"
|
||||
"copyEmailPreview" = "Prévia do email resultante"
|
||||
"copySelectSourceFirst" = "Selecione primeiro uma entrada de origem."
|
||||
"copyResult" = "Resultado da cópia"
|
||||
"copyResultSuccess" = "Copiado com sucesso"
|
||||
"copyResultNone" = "Nada a copiar: nenhum cliente selecionado ou origem vazia"
|
||||
"copyResultErrors" = "Erros ao copiar"
|
||||
"copyFlowLabel" = "Flow para novos clientes (VLESS)"
|
||||
"copyFlowHint" = "Aplicado a todos os clientes copiados. Deixe em branco para ignorar."
|
||||
"selectAll" = "Selecionar tudo"
|
||||
"clearAll" = "Limpar tudo"
|
||||
"method" = "Método"
|
||||
"first" = "Primeiro"
|
||||
"last" = "Último"
|
||||
|
|
|
|||
|
|
@ -298,6 +298,20 @@
|
|||
"submitEdit" = "Сохранить изменения"
|
||||
"clientCount" = "Количество клиентов"
|
||||
"bulk" = "Добавить несколько"
|
||||
"copyFromInbound" = "Скопировать клиентов из инбаунда"
|
||||
"copyToInbound" = "Скопировать клиентов в"
|
||||
"copySelected" = "Скопировать выбранных"
|
||||
"copySource" = "Источник"
|
||||
"copyEmailPreview" = "Предпросмотр итоговых email"
|
||||
"copySelectSourceFirst" = "Сначала выберите источник."
|
||||
"copyResult" = "Результат копирования"
|
||||
"copyResultSuccess" = "Успешно скопировано"
|
||||
"copyResultNone" = "Нечего копировать: ни одного клиента не выбрано или список источника пуст"
|
||||
"copyResultErrors" = "Ошибки при копировании"
|
||||
"copyFlowLabel" = "Flow для новых клиентов (VLESS)"
|
||||
"copyFlowHint" = "Применится ко всем копируемым клиентам. Оставьте пустым, чтобы не задавать."
|
||||
"selectAll" = "Выбрать всех"
|
||||
"clearAll" = "Снять всё"
|
||||
"method" = "Метод"
|
||||
"first" = "Первый"
|
||||
"last" = "Последний"
|
||||
|
|
|
|||
|
|
@ -298,6 +298,20 @@
|
|||
"submitEdit" = "Değişiklikleri Kaydet"
|
||||
"clientCount" = "Müşteri Sayısı"
|
||||
"bulk" = "Toplu Ekle"
|
||||
"copyFromInbound" = "Gelen bağlantıdan istemcileri kopyala"
|
||||
"copyToInbound" = "İstemcileri şuraya kopyala"
|
||||
"copySelected" = "Seçilenleri kopyala"
|
||||
"copySource" = "Kaynak"
|
||||
"copyEmailPreview" = "Sonuç e-posta önizlemesi"
|
||||
"copySelectSourceFirst" = "Önce bir kaynak gelen bağlantı seçin."
|
||||
"copyResult" = "Kopyalama sonucu"
|
||||
"copyResultSuccess" = "Başarıyla kopyalandı"
|
||||
"copyResultNone" = "Kopyalanacak bir şey yok: istemci seçilmedi veya kaynak boş"
|
||||
"copyResultErrors" = "Kopyalama hataları"
|
||||
"copyFlowLabel" = "Yeni istemciler için Flow (VLESS)"
|
||||
"copyFlowHint" = "Kopyalanan tüm istemcilere uygulanır. Boş bırakırsanız atlanır."
|
||||
"selectAll" = "Tümünü seç"
|
||||
"clearAll" = "Tümünü temizle"
|
||||
"method" = "Yöntem"
|
||||
"first" = "İlk"
|
||||
"last" = "Son"
|
||||
|
|
|
|||
|
|
@ -298,6 +298,20 @@
|
|||
"submitEdit" = "Зберегти зміни"
|
||||
"clientCount" = "Кількість клієнтів"
|
||||
"bulk" = "Додати групу"
|
||||
"copyFromInbound" = "Скопіювати клієнтів з інбаунда"
|
||||
"copyToInbound" = "Скопіювати клієнтів у"
|
||||
"copySelected" = "Скопіювати вибраних"
|
||||
"copySource" = "Джерело"
|
||||
"copyEmailPreview" = "Попередній перегляд підсумкових email"
|
||||
"copySelectSourceFirst" = "Спочатку виберіть джерело."
|
||||
"copyResult" = "Результат копіювання"
|
||||
"copyResultSuccess" = "Успішно скопійовано"
|
||||
"copyResultNone" = "Нічого копіювати: жодного клієнта не вибрано або список джерела порожній"
|
||||
"copyResultErrors" = "Помилки під час копіювання"
|
||||
"copyFlowLabel" = "Flow для нових клієнтів (VLESS)"
|
||||
"copyFlowHint" = "Застосується до всіх скопійованих клієнтів. Залиште порожнім, щоб не задавати."
|
||||
"selectAll" = "Вибрати всіх"
|
||||
"clearAll" = "Зняти все"
|
||||
"method" = "Метод"
|
||||
"first" = "Перший"
|
||||
"last" = "Останній"
|
||||
|
|
|
|||
|
|
@ -298,6 +298,20 @@
|
|||
"submitEdit" = "Lưu thay đổi"
|
||||
"clientCount" = "Số lượng người dùng"
|
||||
"bulk" = "Thêm hàng loạt"
|
||||
"copyFromInbound" = "Sao chép người dùng từ Inbound"
|
||||
"copyToInbound" = "Sao chép người dùng đến"
|
||||
"copySelected" = "Sao chép đã chọn"
|
||||
"copySource" = "Nguồn"
|
||||
"copyEmailPreview" = "Xem trước email kết quả"
|
||||
"copySelectSourceFirst" = "Vui lòng chọn Inbound nguồn trước."
|
||||
"copyResult" = "Kết quả sao chép"
|
||||
"copyResultSuccess" = "Đã sao chép thành công"
|
||||
"copyResultNone" = "Không có gì để sao chép: chưa chọn người dùng hoặc nguồn trống"
|
||||
"copyResultErrors" = "Lỗi sao chép"
|
||||
"copyFlowLabel" = "Flow cho người dùng mới (VLESS)"
|
||||
"copyFlowHint" = "Áp dụng cho tất cả người dùng được sao chép. Để trống để bỏ qua."
|
||||
"selectAll" = "Chọn tất cả"
|
||||
"clearAll" = "Bỏ chọn tất cả"
|
||||
"method" = "Phương pháp"
|
||||
"first" = "Đầu tiên"
|
||||
"last" = "Cuối cùng"
|
||||
|
|
|
|||
|
|
@ -298,6 +298,20 @@
|
|||
"submitEdit" = "保存修改"
|
||||
"clientCount" = "客户端数量"
|
||||
"bulk" = "批量创建"
|
||||
"copyFromInbound" = "从入站复制客户端"
|
||||
"copyToInbound" = "复制客户端到"
|
||||
"copySelected" = "复制所选"
|
||||
"copySource" = "来源"
|
||||
"copyEmailPreview" = "最终邮箱预览"
|
||||
"copySelectSourceFirst" = "请先选择来源入站。"
|
||||
"copyResult" = "复制结果"
|
||||
"copyResultSuccess" = "复制成功"
|
||||
"copyResultNone" = "没有可复制的内容:未选择客户端或来源为空"
|
||||
"copyResultErrors" = "复制错误"
|
||||
"copyFlowLabel" = "新客户端的 Flow (VLESS)"
|
||||
"copyFlowHint" = "应用于所有复制的客户端。留空则跳过。"
|
||||
"selectAll" = "全选"
|
||||
"clearAll" = "全不选"
|
||||
"method" = "方法"
|
||||
"first" = "置顶"
|
||||
"last" = "置底"
|
||||
|
|
|
|||
|
|
@ -298,6 +298,20 @@
|
|||
"submitEdit" = "儲存修改"
|
||||
"clientCount" = "客戶端數量"
|
||||
"bulk" = "批量建立"
|
||||
"copyFromInbound" = "從入站複製用戶端"
|
||||
"copyToInbound" = "複製用戶端到"
|
||||
"copySelected" = "複製所選"
|
||||
"copySource" = "來源"
|
||||
"copyEmailPreview" = "最終郵箱預覽"
|
||||
"copySelectSourceFirst" = "請先選擇來源入站。"
|
||||
"copyResult" = "複製結果"
|
||||
"copyResultSuccess" = "複製成功"
|
||||
"copyResultNone" = "沒有可複製的內容:未選擇用戶端或來源為空"
|
||||
"copyResultErrors" = "複製錯誤"
|
||||
"copyFlowLabel" = "新用戶端的 Flow (VLESS)"
|
||||
"copyFlowHint" = "套用於所有複製的用戶端。留空則略過。"
|
||||
"selectAll" = "全選"
|
||||
"clearAll" = "全不選"
|
||||
"method" = "方法"
|
||||
"first" = "置頂"
|
||||
"last" = "置底"
|
||||
|
|
|
|||
Loading…
Reference in a new issue