mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-08 14:36:13 +00:00
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user