diff --git a/internal/web/service/inbound_node.go b/internal/web/service/inbound_node.go index 938512196..e830009ac 100644 --- a/internal/web/service/inbound_node.go +++ b/internal/web/service/inbound_node.go @@ -716,8 +716,12 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi newInboundIDs := make(map[int]struct{}) + // Every pass below walks a central inbound's clients once per snapshot entry that + // resolves to it, so only the first such entry is merged; repeats are dropped. + mergedCentral := make(map[int]struct{}, len(central)) + snapTags := make(map[string]struct{}, len(snap.Inbounds)) - for _, snapIb := range snap.Inbounds { + for snapIdx, snapIb := range snap.Inbounds { if snapIb == nil { continue } @@ -761,6 +765,13 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi return false, fmt.Errorf("setRemoteTraffic: inbound %q has ambiguous compatible central aliases", snapIb.Tag) } } + if ok { + if _, merged := mergedCentral[c.Id]; merged { + snap.Inbounds[snapIdx] = nil + continue + } + mergedCentral[c.Id] = struct{}{} + } if !ok { if dirty { continue @@ -839,6 +850,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi } } newInboundIDs[newIb.Id] = struct{}{} + mergedCentral[newIb.Id] = struct{}{} structuralChange = true continue } @@ -929,9 +941,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi if dirty { continue } - // A node inbound created disabled is never delivered, so its absence from - // the snapshot is ambiguous rather than evidence of a node-side delete. - if !c.Enable { + // A node inbound created disabled is never delivered, so its absence from the snapshot + // is ambiguous; a copy the node once reported (it has an origin) is not. + if !c.Enable && c.OriginNodeGuid == "" { continue } if len(snapTags) == 0 { diff --git a/internal/web/service/node_sweep_guard_test.go b/internal/web/service/node_sweep_guard_test.go index 76d1cace4..f006ad748 100644 --- a/internal/web/service/node_sweep_guard_test.go +++ b/internal/web/service/node_sweep_guard_test.go @@ -2,6 +2,7 @@ package service import ( "fmt" + "slices" "testing" "github.com/mhsanaei/3x-ui/v3/internal/database" @@ -160,3 +161,55 @@ func TestSyncInboundStoresTrimmedEmail(t *testing.T) { t.Fatalf("links after re-sync = %d, want 1", links) } } + +// Only a master-created inbound not yet delivered is exempt from the sweep; otherwise a +// node could report endless disabled inbounds that the master keeps forever. +func TestDisabledInboundTheNodeReportedIsSwept(t *testing.T) { + db := initTrafficTestDB(t) + svc := &InboundService{} + seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true}) + keep := snapshotWithClients(t, "keep", `{"clients":[]}`) + reported := &runtime.TrafficSnapshot{Inbounds: append(keep.Inbounds, + &model.Inbound{Tag: "junk", Port: 41003, Protocol: model.VLESS, Enable: false, Settings: `{"clients":[]}`})} + if _, err := svc.setRemoteTrafficLocked(1, reported, false, false); err != nil { + t.Fatalf("first sync: %v", err) + } + nid := 1 + if err := db.Create(&model.Inbound{UserId: 1, Tag: "undelivered", Port: 41004, Protocol: model.VLESS, NodeID: &nid, Settings: `{"clients":[]}`}).Error; err != nil { + t.Fatalf("create undelivered inbound: %v", err) + } + if err := db.Model(&model.Inbound{}).Where("tag = ?", "undelivered").Update("enable", false).Error; err != nil { + t.Fatalf("disable: %v", err) + } + if _, err := svc.setRemoteTrafficLocked(1, snapshotWithClients(t, "keep", `{"clients":[]}`), false, false); err != nil { + t.Fatalf("second sync: %v", err) + } + var tags []string + db.Model(&model.Inbound{}).Order("tag").Pluck("tag", &tags) + if !slices.Equal(tags, []string{"keep", "undelivered"}) { + t.Fatalf("inbounds after the node stopped reporting junk = %v, want [keep undelivered]", tags) + } +} + +// A node listing one inbound many times made every pass repeat its whole client set; +// the first entry for an inbound is merged and the repeats are dropped. +func TestRepeatedSnapshotEntriesMergeTheInboundOnce(t *testing.T) { + db := initTrafficTestDB(t) + svc := &InboundService{} + seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true}) + snap := &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{ + {Tag: "dup", Port: 41005, Protocol: model.VLESS, Enable: true, Remark: "first", Settings: `{"clients":[]}`}, + {Tag: "dup", Port: 41005, Protocol: model.VLESS, Enable: true, Remark: "second", Settings: `{"clients":[]}`}, + }} + for range 2 { + if _, err := svc.setRemoteTrafficLocked(1, snap, false, false); err != nil { + t.Fatalf("sync: %v", err) + } + snap.Inbounds[1] = &model.Inbound{Tag: "dup", Port: 41005, Protocol: model.VLESS, Enable: true, Remark: "second", Settings: `{"clients":[]}`} + } + var remarks []string + db.Model(&model.Inbound{}).Where("tag = ?", "dup").Pluck("remark", &remarks) + if !slices.Equal(remarks, []string{"first"}) { + t.Fatalf("remarks = %v, want the first entry merged once", remarks) + } +}