diff --git a/internal/web/service/inbound_node.go b/internal/web/service/inbound_node.go index f761d900b..184739af5 100644 --- a/internal/web/service/inbound_node.go +++ b/internal/web/service/inbound_node.go @@ -499,6 +499,15 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi if dirty { continue } + if len(snapTags) == 0 { + // A node mid-restart or with a transient DB error can return an empty + // inbound list with success=true. Treat "zero inbounds reported" as + // "nothing to say", not "delete all my inbounds" — otherwise a blip + // wipes the node's central inbounds and every client on them (and + // resets traffic history on re-create). A real per-inbound deletion + // still sweeps, because the node keeps reporting its other inbounds. + continue + } if _, kept := snapTags[c.Tag]; kept { continue } diff --git a/internal/web/service/node_origin_guid_test.go b/internal/web/service/node_origin_guid_test.go index 765398c9d..672ff34a7 100644 --- a/internal/web/service/node_origin_guid_test.go +++ b/internal/web/service/node_origin_guid_test.go @@ -131,6 +131,43 @@ func TestSetRemoteTraffic_RemapsClonedNodeOwnGuidOrigin(t *testing.T) { } } +// A node mid-restart can return an empty inbound list with success=true. The +// sync must NOT treat that as "delete all my inbounds" — otherwise a blip wipes +// the node's central inbounds and every client on them (what happened to the +// Germany node: 0 clients but still online). +func TestSetRemoteTraffic_EmptySnapshotKeepsCentralInbounds(t *testing.T) { + setupConflictDB(t) + db := database.GetDB() + + const nodeID = 1 + if err := db.Create(&model.Node{ + Id: nodeID, Name: "n", Address: "10.0.0.1", Port: 2053, ApiToken: "t", Guid: "g", + }).Error; err != nil { + t.Fatalf("create node: %v", err) + } + nidPtr := nodeID + if err := db.Create(&model.Inbound{ + UserId: 1, NodeID: &nidPtr, Tag: "remote-in", Enable: true, + Port: 443, Protocol: model.VLESS, Settings: `{"clients":[]}`, + }).Error; err != nil { + t.Fatalf("create central inbound: %v", err) + } + + // Empty snapshot — the node reported no inbounds this cycle. + svc := InboundService{} + if _, err := svc.setRemoteTrafficLocked(nodeID, &runtime.TrafficSnapshot{}, false); err != nil { + t.Fatalf("setRemoteTrafficLocked: %v", err) + } + + var count int64 + if err := db.Model(&model.Inbound{}).Where("tag = ?", "remote-in").Count(&count).Error; err != nil { + t.Fatalf("count inbounds: %v", err) + } + if count != 1 { + t.Fatalf("empty snapshot must not delete the central inbound; got count = %d", count) + } +} + func TestSetRemoteTraffic_PreservesLocalShareAddressStrategy(t *testing.T) { setupConflictDB(t) db := database.GetDB()