diff --git a/internal/database/model/model.go b/internal/database/model/model.go index 25cc620dc..c3edf052f 100644 --- a/internal/database/model/model.go +++ b/internal/database/model/model.go @@ -823,8 +823,8 @@ type Node struct { ConfigDirty bool `json:"configDirty" gorm:"default:false"` ConfigDirtyAt int64 `json:"configDirtyAt"` - // InboundsAdoptedAt records the first clean traffic sync that imported the - // node's pre-existing inbounds; reconcile must not sweep remote tags before it. + // InboundsAdoptedAt is the clean sync that imported the node's inbounds; a + // save that grows the selection zeroes it so reconcile waits before sweeping. InboundsAdoptedAt int64 `json:"-" gorm:"column:inbounds_adopted_at;default:0"` InboundCount int `json:"inboundCount" gorm:"-" example:"5"` diff --git a/internal/web/service/inbound_node.go b/internal/web/service/inbound_node.go index 7018e6698..3d789396b 100644 --- a/internal/web/service/inbound_node.go +++ b/internal/web/service/inbound_node.go @@ -172,7 +172,7 @@ func (s *InboundService) ReconcileNode(ctx context.Context, rt *runtime.Remote, errs = append(errs, fmt.Errorf("reconcile inbound %q: %w", ib.Tag, err)) } } - // Before the first clean sync adopts the node's inbounds, "absent locally" + // Before the next clean sync adopts the node's inbounds, "absent locally" // means "not imported yet" — sweeping now would wipe the node at onboarding. if n.InboundsAdoptedAt == 0 { return errors.Join(errs...) diff --git a/internal/web/service/inbound_node_reconcile_test.go b/internal/web/service/inbound_node_reconcile_test.go index d10c69008..2075f1fc1 100644 --- a/internal/web/service/inbound_node_reconcile_test.go +++ b/internal/web/service/inbound_node_reconcile_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "slices" "sort" "strconv" "strings" @@ -431,3 +432,71 @@ func TestReconcileNode_SelectedModeSweepsPrefixedSelectedTag(t *testing.T) { t.Fatalf("deleted remote ids = %v, want [2] (prefixed selected tag must be swept, unmanaged 3 must survive)", got) } } + +// Saving the node form marks the node dirty in the same transaction that grows +// its managed set, so reconcile would sweep a tag the panel has not imported yet. +func TestReconcileNode_SaveGrowingSelectionRearmsSweepGuard(t *testing.T) { + cases := []struct { + name string + storedTags []string + mode string + tags []string + wantDeleted []int + }{ + { + name: "newly selected tag is imported, not swept", + storedTags: []string{"keep"}, + mode: "selected", + tags: []string{"keep", "fresh"}, + wantDeleted: nil, + }, + { + name: "switch to all mode imports before sweeping", + storedTags: []string{"keep"}, + mode: "all", + wantDeleted: nil, + }, + { + name: "unchanged selection still sweeps a deleted tag", + storedTags: []string{"keep", "gone"}, + mode: "selected", + tags: []string{"keep", "gone"}, + wantDeleted: []int{3}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + setupConflictDB(t) + ts, deletedIDs := fakeNodePanel(t, map[string]int{"keep": 1, "fresh": 2, "gone": 3}) + node := reconcileTestNode(t, ts, "grow-node", "selected", tc.storedTags) + seedInboundConflictNode(t, "keep", "", 443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, &node.Id) + + err := (&NodeService{}).UpdateFromRequest(node.Id, &NodeMutationRequest{ + Name: node.Name, + Scheme: node.Scheme, + Address: node.Address, + Port: node.Port, + BasePath: node.BasePath, + Enable: true, + AllowPrivateAddress: true, + InboundSyncMode: tc.mode, + InboundTags: tc.tags, + }) + if err != nil { + t.Fatalf("UpdateFromRequest: %v", err) + } + saved := &model.Node{} + if err := database.GetDB().First(saved, node.Id).Error; err != nil { + t.Fatalf("reload node: %v", err) + } + + svc := InboundService{} + if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(saved, nil), saved); err != nil { + t.Fatalf("ReconcileNode: %v", err) + } + if got := deletedIDs(); !slices.Equal(got, tc.wantDeleted) { + t.Fatalf("deleted remote ids = %v, want %v", got, tc.wantDeleted) + } + }) + } +} diff --git a/internal/web/service/node.go b/internal/web/service/node.go index 4c5184594..2851e26a0 100644 --- a/internal/web/service/node.go +++ b/internal/web/service/node.go @@ -488,6 +488,24 @@ func (s *NodeService) CreateFromRequest(req *NodeMutationRequest) (*NodeView, er return toNodeView(n), nil } +// nodeSelectionGrew reports a save that starts managing inbounds the panel has +// not imported yet; the sweep must wait for the next clean sync to adopt them. +func nodeSelectionGrew(existing, in *model.Node) bool { + if in.InboundSyncMode != "selected" { + return existing.InboundSyncMode == "selected" + } + old := make(map[string]struct{}, len(existing.InboundTags)) + for _, tag := range existing.InboundTags { + old[tag] = struct{}{} + } + for _, tag := range in.InboundTags { + if _, ok := old[tag]; !ok { + return true + } + } + return false +} + func (s *NodeService) Update(id int, in *model.Node) error { if err := s.normalize(in); err != nil { return err @@ -526,6 +544,9 @@ func (s *NodeService) Update(id int, in *model.Node) error { "inbound_tags": string(inboundTagsJSON), "outbound_tag": in.OutboundTag, } + if nodeSelectionGrew(existing, in) { + updates["inbounds_adopted_at"] = 0 + } if err := db.Transaction(func(tx *gorm.DB) error { if err := tx.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil { return err @@ -586,6 +607,9 @@ func (s *NodeService) UpdateFromRequest(id int, req *NodeMutationRequest) error "inbound_tags": string(inboundTagsJSON), "outbound_tag": in.OutboundTag, } + if nodeSelectionGrew(existing, in) { + updates["inbounds_adopted_at"] = 0 + } if err := db.Transaction(func(tx *gorm.DB) error { if err := tx.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil { return err