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.
This commit is contained in:
MHSanaei
2026-11-01 13:26:10 +01:00
parent 2e41f908db
commit 5a6d40e3ff
2 changed files with 60 additions and 10 deletions
+7 -10
View File
@@ -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)
@@ -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)
}
}