diff --git a/internal/web/service/inbound_node.go b/internal/web/service/inbound_node.go index 13acdbb37..8f3a71d6a 100644 --- a/internal/web/service/inbound_node.go +++ b/internal/web/service/inbound_node.go @@ -714,6 +714,12 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi if dirty { continue } + // Disabled inbounds are intentionally absent from the node's runtime + // snapshot. Their absence is not evidence of deletion; retain the row, + // client history and port reservation until an explicit delete occurs. + if !c.Enable { + 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 diff --git a/internal/web/service/node_dirty_test.go b/internal/web/service/node_dirty_test.go index 26f89d6e3..de4872e1d 100644 --- a/internal/web/service/node_dirty_test.go +++ b/internal/web/service/node_dirty_test.go @@ -68,6 +68,43 @@ func TestSetRemoteTraffic_DirtyPreservesConfig(t *testing.T) { } } +func TestSetRemoteTraffic_MissingDisabledInboundIsNotSwept(t *testing.T) { + setupConflictDB(t) + db := database.GetDB() + node := &model.Node{Name: "disabled-snapshot", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true, Status: "online"} + if err := db.Create(node).Error; err != nil { + t.Fatal(err) + } + disabled := &model.Inbound{ + UserId: 1, NodeID: &node.Id, Tag: "disabled", Enable: false, + Port: 24443, Protocol: model.VLESS, Settings: `{"clients":[]}`, + } + reported := &model.Inbound{ + UserId: 1, NodeID: &node.Id, Tag: "reported", Enable: true, + Port: 24444, Protocol: model.VLESS, Settings: `{"clients":[]}`, + } + if err := db.Create(disabled).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(reported).Error; err != nil { + t.Fatal(err) + } + snap := &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{{ + Tag: reported.Tag, Enable: true, + Port: reported.Port, Protocol: reported.Protocol, Settings: reported.Settings, + }}} + if _, err := (&InboundService{}).setRemoteTrafficLocked(node.Id, snap, false); err != nil { + t.Fatal(err) + } + var count int64 + if err := db.Model(&model.Inbound{}).Where("id=?", disabled.Id).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("disabled inbound rows=%d, want 1", count) + } +} + // Deleting a *disabled* client attached to a node inbound must still propagate // to the node. The node's own DB carries the (disabled) client, so the central // panel has to mark the node dirty (→ reconcile) instead of dropping the delete