Просмотр исходного кода

fix(multi-node): scope remote client update/delete to one inbound (#4892)

UpdateUser and DeleteUser hit the node's email-based full-client endpoints, which fanned out to every inbound the client had on the node: editing a client wiped flow on the node's other inbounds, and detaching one node inbound deleted the client from all of them.

Make both inbound-scoped, mirroring AddClient. DeleteUser now detaches the resolved remote inbound id; UpdateUser passes an inboundIds scope so the node updates only that inbound.
MHSanaei 1 день назад
Родитель
Сommit
db86007ab8
3 измененных файлов с 51 добавлено и 13 удалено
  1. 19 1
      web/controller/client.go
  2. 16 9
      web/runtime/remote.go
  3. 16 3
      web/service/client.go

+ 19 - 1
web/controller/client.go

@@ -3,6 +3,8 @@ package controller
 import (
 	"encoding/json"
 	"fmt"
+	"strconv"
+	"strings"
 	"time"
 
 	"github.com/mhsanaei/3x-ui/v3/database/model"
@@ -16,6 +18,21 @@ func notifyClientsChanged() {
 	websocket.BroadcastInvalidate(websocket.MessageTypeClients)
 }
 
+func parseInboundIdsQuery(raw string) []int {
+	raw = strings.TrimSpace(raw)
+	if raw == "" {
+		return nil
+	}
+	parts := strings.Split(raw, ",")
+	ids := make([]int, 0, len(parts))
+	for _, p := range parts {
+		if id, err := strconv.Atoi(strings.TrimSpace(p)); err == nil {
+			ids = append(ids, id)
+		}
+	}
+	return ids
+}
+
 type ClientController struct {
 	clientService  service.ClientService
 	inboundService service.InboundService
@@ -129,7 +146,8 @@ func (a *ClientController) update(c *gin.Context) {
 		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
 		return
 	}
-	needRestart, err := a.clientService.UpdateByEmail(&a.inboundService, email, updated)
+	inboundFilter := parseInboundIdsQuery(c.Query("inboundIds"))
+	needRestart, err := a.clientService.UpdateByEmail(&a.inboundService, email, updated, inboundFilter...)
 	if err != nil {
 		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
 		return

+ 16 - 9
web/runtime/remote.go

@@ -286,15 +286,17 @@ func (r *Remote) AddClient(ctx context.Context, ib *model.Inbound, client model.
 	return nil
 }
 
-// DeleteUser is idempotent: master's per-inbound Delete loop may call it
-// multiple times for the same node, and "not found" on the follow-ups is
-// the expected success path.
-func (r *Remote) DeleteUser(ctx context.Context, _ *model.Inbound, email string) error {
+func (r *Remote) DeleteUser(ctx context.Context, ib *model.Inbound, email string) error {
 	if email == "" {
 		return nil
 	}
-	_, err := r.do(ctx, http.MethodPost,
-		"panel/api/clients/del/"+url.PathEscape(email), nil)
+	id, err := r.resolveRemoteID(ctx, ib.Tag)
+	if err != nil {
+		return nil
+	}
+	body := map[string]any{"inboundIds": []int{id}}
+	_, err = r.do(ctx, http.MethodPost,
+		"panel/api/clients/"+url.PathEscape(email)+"/detach", body)
 	if err == nil {
 		return nil
 	}
@@ -304,12 +306,17 @@ func (r *Remote) DeleteUser(ctx context.Context, _ *model.Inbound, email string)
 	return err
 }
 
-func (r *Remote) UpdateUser(ctx context.Context, _ *model.Inbound, oldEmail string, payload model.Client) error {
+func (r *Remote) UpdateUser(ctx context.Context, ib *model.Inbound, oldEmail string, payload model.Client) error {
 	if oldEmail == "" {
 		oldEmail = payload.Email
 	}
-	if _, err := r.do(ctx, http.MethodPost,
-		"panel/api/clients/update/"+url.PathEscape(oldEmail), payload); err != nil {
+	id, err := r.resolveRemoteID(ctx, ib.Tag)
+	if err != nil {
+		return err
+	}
+	path := "panel/api/clients/update/" + url.PathEscape(oldEmail) +
+		"?inboundIds=" + strconv.Itoa(id)
+	if _, err := r.do(ctx, http.MethodPost, path, payload); err != nil {
 		return err
 	}
 	return nil

+ 16 - 3
web/service/client.go

@@ -634,7 +634,7 @@ func applyShadowsocksClientMethod(clients []any, settings map[string]any) {
 	}
 }
 
-func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model.Client) (bool, error) {
+func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model.Client, inboundFilter ...int) (bool, error) {
 	existing, err := s.GetByID(id)
 	if err != nil {
 		return false, err
@@ -643,6 +643,19 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
 	if err != nil {
 		return false, err
 	}
+	if len(inboundFilter) > 0 {
+		allow := make(map[int]struct{}, len(inboundFilter))
+		for _, fid := range inboundFilter {
+			allow[fid] = struct{}{}
+		}
+		filtered := inboundIds[:0:0]
+		for _, ibId := range inboundIds {
+			if _, ok := allow[ibId]; ok {
+				filtered = append(filtered, ibId)
+			}
+		}
+		inboundIds = filtered
+	}
 
 	if strings.TrimSpace(updated.Email) == "" {
 		return false, common.NewError("client email is required")
@@ -1317,7 +1330,7 @@ func (s *ClientService) findInboundIdsByClientEmail(email string) ([]int, error)
 	return out, nil
 }
 
-func (s *ClientService) UpdateByEmail(inboundSvc *InboundService, email string, updated model.Client) (bool, error) {
+func (s *ClientService) UpdateByEmail(inboundSvc *InboundService, email string, updated model.Client, inboundFilter ...int) (bool, error) {
 	if email == "" {
 		return false, common.NewError("client email is required")
 	}
@@ -1325,7 +1338,7 @@ func (s *ClientService) UpdateByEmail(inboundSvc *InboundService, email string,
 	if err != nil {
 		return false, err
 	}
-	return s.Update(inboundSvc, rec.Id, updated)
+	return s.Update(inboundSvc, rec.Id, updated, inboundFilter...)
 }
 
 func (s *ClientService) ResetTrafficByEmail(inboundSvc *InboundService, email string) (bool, error) {