Feature: Copy clients between inbounds (#4087)

* 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:
Rs.Nest
2026-04-23 16:19:07 +03:00
committed by GitHub
parent ff25072690
commit 6bcaf61c44
16 changed files with 641 additions and 0 deletions

View File

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