From 5a6d40e3ff76c83a006d39ef7a02373e66f9547a Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 13:26:10 +0100 Subject: [PATCH] fix(inbounds): keep disabled clients out of the core when adding an inbound The running core must only see clients the panel has enabled, and UpdateInbound and SetInboundEnable already build their payload through buildInboundForLocalRuntime, which strips disabled ones. AddInbound used it only for MTProto and TUIC and hot-added the stored row for every other protocol, so an imported or pushed inbound re-armed its disabled clients until the next restart. AddInbound now builds the runtime payload the same way for every protocol. --- internal/web/service/inbound.go | 17 +++--- .../service/inbound_update_hot_users_test.go | 53 +++++++++++++++++++ 2 files changed, 60 insertions(+), 10 deletions(-) diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index a0296cb8f..50a875721 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -1574,17 +1574,14 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo return perr } if push { - payload := inbound - pushable := true - if inbound.Protocol == model.MTProto || inbound.Protocol == model.TUIC { - if built, bErr := s.buildInboundForLocalRuntime(tx, inbound); bErr == nil { - payload = built - } else { - logger.Debug("Unable to prepare runtime inbound config:", bErr) - pushable = false + // An imported or master-pushed row lists its disabled and depleted clients too. + payload, bErr := s.buildInboundForLocalRuntime(tx, inbound) + if bErr != nil { + logger.Debug("Unable to prepare runtime inbound config:", bErr) + if inbound.Protocol != model.MTProto && inbound.Protocol != model.TUIC { + needRestart = true } - } - if pushable { + } else { postCommitApply = func() { if err1 := rt.AddInbound(context.Background(), payload); err1 == nil { logger.Debug("New inbound added on", rt.Name(), ":", inbound.Tag) diff --git a/internal/web/service/inbound_update_hot_users_test.go b/internal/web/service/inbound_update_hot_users_test.go index 329f81480..ec26c284b 100644 --- a/internal/web/service/inbound_update_hot_users_test.go +++ b/internal/web/service/inbound_update_hot_users_test.go @@ -11,6 +11,7 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/xray" ) // inboundOpsProbe records every handler and user operation the panel sends the @@ -322,3 +323,55 @@ func TestNodeShadowsocksPushChangingOnlyClientsKeepsTheHandler(t *testing.T) { } } } + +// addInboundRecorder keeps the payload handed to the local runtime, so a test can +// check what the running core would register rather than what the DB stores. +type addInboundRecorder struct { + fakeNodeRuntime + added *model.Inbound +} + +func (r *addInboundRecorder) AddInbound(_ context.Context, ib *model.Inbound) error { + r.added = ib + return nil +} + +// An imported or master-pushed inbound arrives with its clients, switched-off +// ones included; only UpdateInbound/SetInboundEnable used to strip them. +func TestAddInboundKeepsDisabledClientsOutOfTheCore(t *testing.T) { + setupConflictDB(t) + mgr := useTestRuntimeManager(t) + local := &addInboundRecorder{} + mgr.SetLocalRuntimeOverride(local) + + svc := &InboundService{} + saved, needRestart, err := svc.AddInbound(&model.Inbound{ + UserId: 1, Enable: true, Port: 44490, Protocol: model.VLESS, + StreamSettings: `{"network":"tcp","security":"none"}`, + Settings: `{"decryption":"none","clients":[ + {"email":"paying","id":"5f2eb9d6-3a2f-4a55-9812-6ea1e2f7a001","enable":true}, + {"email":"banned","id":"5f2eb9d6-3a2f-4a55-9812-6ea1e2f7a002","enable":false}, + {"email":"depleted","id":"5f2eb9d6-3a2f-4a55-9812-6ea1e2f7a003","enable":true}]}`, + ClientStats: []xray.ClientTraffic{{Email: "depleted", Enable: false, Up: 10, Total: 10}}, + }) + if err != nil { + t.Fatalf("AddInbound: %v", err) + } + if needRestart || local.added == nil { + t.Fatalf("the inbound must be hot-added to the running core; needRestart=%v added=%v", needRestart, local.added) + } + served, err := svc.GetClients(local.added) + if err != nil { + t.Fatalf("GetClients(runtime payload): %v", err) + } + if len(served) != 1 || served[0].Email != "paying" { + t.Fatalf("the core must get only the enabled client, got %s", local.added.Settings) + } + stored, err := svc.GetClients(saved) + if err != nil { + t.Fatalf("GetClients(stored row): %v", err) + } + if len(stored) != 3 { + t.Fatalf("the stored row must keep every client, got %s", saved.Settings) + } +}