From e0ac65a05f6f0625f4ee956a1fe7740e885feb18 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Mon, 22 Jun 2026 16:32:33 +0200 Subject: [PATCH] fix(node-sync): don't delete a node's central inbounds when its snapshot is empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The central-inbound sweep deletes any central inbound whose tag is absent from the node's snapshot, with no guard for an empty snapshot. A node mid-restart or with a transient DB error (e.g. Postgres 57P01) can return an empty inbound list with success=true, which wiped all of that node's central inbounds and their clients (and reset traffic history on re-create) — observed on the Germany node: 0 clients but still 44 online (online survives because it comes from the snapshot's online tree, not the central inbound). Skip the sweep entirely when the snapshot reports zero inbounds; a real per-inbound deletion still sweeps via a non-empty snapshot that omits one tag. --- internal/web/service/inbound_node.go | 9 +++++ internal/web/service/node_origin_guid_test.go | 37 +++++++++++++++++++ 2 files changed, 46 insertions(+) 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()