From db86007ab8016226502628bfe905033bf99b619e Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Thu, 4 Jun 2026 16:45:40 +0200 Subject: [PATCH] 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. --- web/controller/client.go | 20 +++++++++++++++++++- web/runtime/remote.go | 25 ++++++++++++++++--------- web/service/client.go | 19 ++++++++++++++++--- 3 files changed, 51 insertions(+), 13 deletions(-) diff --git a/web/controller/client.go b/web/controller/client.go index cb2165f4..36d02f04 100644 --- a/web/controller/client.go +++ b/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 diff --git a/web/runtime/remote.go b/web/runtime/remote.go index b525ba5f..1e5ba422 100644 --- a/web/runtime/remote.go +++ b/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 diff --git a/web/service/client.go b/web/service/client.go index de5fde65..e7131ddd 100644 --- a/web/service/client.go +++ b/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) {