From 67d24ca0e66464d279fd17b86031da620d9c8cf9 Mon Sep 17 00:00:00 2001 From: Sora39831 <540587985@qq.com> Date: Sun, 5 Apr 2026 03:52:41 +0800 Subject: [PATCH] fix(user): sync-remove inbound clients when deleting managed user --- web/controller/user.go | 2 +- web/service/user.go | 76 +++++++++++++++++++-- web/service/user_test.go | 140 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 213 insertions(+), 5 deletions(-) diff --git a/web/controller/user.go b/web/controller/user.go index 6e3ccb07..71849264 100644 --- a/web/controller/user.go +++ b/web/controller/user.go @@ -105,7 +105,7 @@ func (a *UserController) deleteUser(c *gin.Context) { } currentUser := session.GetLoginUser(c) - err = a.userService.DeleteUser(id, currentUser.Id) + err = a.userService.DeleteUser(id, currentUser.Id, &a.inboundService) if err != nil { jsonMsg(c, I18nWeb(c, "pages.users.toasts.delete"), a.localizeUserError(c, err)) return diff --git a/web/service/user.go b/web/service/user.go index 55dfa5d1..a9f5c171 100644 --- a/web/service/user.go +++ b/web/service/user.go @@ -151,6 +151,68 @@ func (s *UserService) addUserClientsToAllInbounds(tx *gorm.DB, username string, return nil } +func (s *UserService) removeUserClientsFromAllInbounds(tx *gorm.DB, username string, inboundService *InboundService) error { + inbounds, err := inboundService.GetAllInbounds() + if err != nil { + return err + } + + for _, inbound := range inbounds { + var settings map[string]any + if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil { + return err + } + + clientsRaw, ok := settings["clients"].([]any) + if !ok { + continue + } + + newClients := make([]any, 0, len(clientsRaw)) + removedEmails := make(map[string]struct{}) + for _, clientRaw := range clientsRaw { + clientMap, ok := clientRaw.(map[string]any) + if !ok { + newClients = append(newClients, clientRaw) + continue + } + + email, _ := clientMap["email"].(string) + if strings.EqualFold(email, username) { + if email != "" { + removedEmails[email] = struct{}{} + } + continue + } + newClients = append(newClients, clientRaw) + } + + if len(removedEmails) == 0 { + continue + } + + settings["clients"] = newClients + newSettings, err := json.Marshal(settings) + if err != nil { + return err + } + if err := tx.Model(&model.Inbound{}).Where("id = ?", inbound.Id).Update("settings", string(newSettings)).Error; err != nil { + return err + } + + for email := range removedEmails { + if err := inboundService.DelClientStat(tx, inbound.Id, email); err != nil { + return err + } + if err := inboundService.DelClientIPs(tx, email); err != nil { + return err + } + } + } + + return nil +} + // GetFirstUser retrieves the first user from the database. // This is typically used for initial setup or when there's only one admin user. func (s *UserService) GetFirstUser() (*model.User, error) { @@ -368,7 +430,7 @@ func (s *UserService) UpdateManagedUser(id int, username string, password string } // DeleteUser deletes a managed user. -func (s *UserService) DeleteUser(id int, currentUserId int) error { +func (s *UserService) DeleteUser(id int, currentUserId int, inboundService *InboundService) error { if id == currentUserId { return ErrCannotDeleteSelf } @@ -392,7 +454,9 @@ func (s *UserService) DeleteUser(id int, currentUserId int) error { } } - inboundService := InboundService{} + if inboundService == nil { + inboundService = &InboundService{} + } inbounds, err := inboundService.GetInbounds(id) if err != nil { return err @@ -402,8 +466,12 @@ func (s *UserService) DeleteUser(id int, currentUserId int) error { return err } } - - return db.Delete(&model.User{}, id).Error + return db.Transaction(func(tx *gorm.DB) error { + if err := s.removeUserClientsFromAllInbounds(tx, user.Username, inboundService); err != nil { + return err + } + return tx.Delete(&model.User{}, id).Error + }) } func (s *UserService) RegisterUser(username string, password string, inboundService *InboundService) error { diff --git a/web/service/user_test.go b/web/service/user_test.go index 6e3c0c59..0d18efea 100644 --- a/web/service/user_test.go +++ b/web/service/user_test.go @@ -1,6 +1,7 @@ package service import ( + "encoding/json" "os" "path/filepath" "testing" @@ -8,6 +9,7 @@ import ( "github.com/mhsanaei/3x-ui/v2/database" "github.com/mhsanaei/3x-ui/v2/database/model" "github.com/mhsanaei/3x-ui/v2/util/crypto" + "github.com/mhsanaei/3x-ui/v2/xray" ) func setupTestDB(t *testing.T) { @@ -145,3 +147,141 @@ func TestUpdateFirstUser_CreateWhenNone(t *testing.T) { t.Error("password hash should match 'firstpass'") } } + +func TestDeleteUser_RemovesClientsFromAllInbounds(t *testing.T) { + setupTestDB(t) + + db := database.GetDB() + userSvc := &UserService{} + inboundSvc := &InboundService{} + + hashedPassword, err := crypto.HashPasswordAsBcrypt("password123") + if err != nil { + t.Fatalf("hash password failed: %v", err) + } + managedUser := &model.User{ + Username: "managed_user", + Password: hashedPassword, + Role: "user", + } + if err := db.Create(managedUser).Error; err != nil { + t.Fatalf("create managed user failed: %v", err) + } + + settings1Bytes, err := json.Marshal(map[string]any{ + "clients": []map[string]any{ + {"id": "client-1", "email": "managed_user", "enable": false}, + {"id": "client-2", "email": "keep_user", "enable": false}, + }, + }) + if err != nil { + t.Fatalf("marshal inbound settings 1 failed: %v", err) + } + settings2Bytes, err := json.Marshal(map[string]any{ + "clients": []map[string]any{ + {"id": "client-3", "email": "managed_user", "enable": false}, + {"id": "client-4", "email": "another_user", "enable": false}, + }, + }) + if err != nil { + t.Fatalf("marshal inbound settings 2 failed: %v", err) + } + + inbound1 := &model.Inbound{ + UserId: 1, + Port: 21001, + Protocol: model.VLESS, + Tag: "user-delete-sync-1", + Settings: string(settings1Bytes), + } + inbound2 := &model.Inbound{ + UserId: 1, + Port: 21002, + Protocol: model.VLESS, + Tag: "user-delete-sync-2", + Settings: string(settings2Bytes), + } + if err := db.Create(inbound1).Error; err != nil { + t.Fatalf("create inbound1 failed: %v", err) + } + if err := db.Create(inbound2).Error; err != nil { + t.Fatalf("create inbound2 failed: %v", err) + } + + if err := db.Create(&xray.ClientTraffic{InboundId: inbound1.Id, Email: "managed_user"}).Error; err != nil { + t.Fatalf("create traffic for inbound1 failed: %v", err) + } + if err := db.Create(&xray.ClientTraffic{InboundId: inbound2.Id, Email: "managed_user"}).Error; err != nil { + t.Fatalf("create traffic for inbound2 failed: %v", err) + } + if err := db.Create(&xray.ClientTraffic{InboundId: inbound1.Id, Email: "keep_user"}).Error; err != nil { + t.Fatalf("create keep_user traffic failed: %v", err) + } + if err := db.Create(&model.InboundClientIps{ClientEmail: "managed_user", Ips: "[\"1.1.1.1\"]"}).Error; err != nil { + t.Fatalf("create inbound client ips failed: %v", err) + } + + if err := userSvc.DeleteUser(managedUser.Id, 1, inboundSvc); err != nil { + t.Fatalf("DeleteUser failed: %v", err) + } + + var usersCount int64 + if err := db.Model(&model.User{}).Where("id = ?", managedUser.Id).Count(&usersCount).Error; err != nil { + t.Fatalf("count users failed: %v", err) + } + if usersCount != 0 { + t.Fatalf("expected managed user to be deleted, remaining=%d", usersCount) + } + + checkInboundHasNoManagedUser := func(inboundID int) { + t.Helper() + var inbound model.Inbound + if err := db.First(&inbound, inboundID).Error; err != nil { + t.Fatalf("load inbound %d failed: %v", inboundID, err) + } + var settings map[string]any + if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil { + t.Fatalf("unmarshal inbound settings failed: %v", err) + } + clients, ok := settings["clients"].([]any) + if !ok { + t.Fatalf("invalid clients format in inbound %d", inboundID) + } + for _, clientRaw := range clients { + clientMap, ok := clientRaw.(map[string]any) + if !ok { + continue + } + email, _ := clientMap["email"].(string) + if email == "managed_user" { + t.Fatalf("managed_user still exists in inbound %d clients", inboundID) + } + } + } + checkInboundHasNoManagedUser(inbound1.Id) + checkInboundHasNoManagedUser(inbound2.Id) + + var managedTrafficCount int64 + if err := db.Model(&xray.ClientTraffic{}).Where("email = ?", "managed_user").Count(&managedTrafficCount).Error; err != nil { + t.Fatalf("count managed user traffic failed: %v", err) + } + if managedTrafficCount != 0 { + t.Fatalf("expected managed_user traffic to be deleted, remaining=%d", managedTrafficCount) + } + + var keepTrafficCount int64 + if err := db.Model(&xray.ClientTraffic{}).Where("email = ?", "keep_user").Count(&keepTrafficCount).Error; err != nil { + t.Fatalf("count keep_user traffic failed: %v", err) + } + if keepTrafficCount != 1 { + t.Fatalf("expected keep_user traffic to remain, got=%d", keepTrafficCount) + } + + var ipsCount int64 + if err := db.Model(&model.InboundClientIps{}).Where("client_email = ?", "managed_user").Count(&ipsCount).Error; err != nil { + t.Fatalf("count inbound client ips failed: %v", err) + } + if ipsCount != 0 { + t.Fatalf("expected managed_user inbound_client_ips to be deleted, remaining=%d", ipsCount) + } +}