From 22346eef78071c5e25ecccfa54f2c68f0c290b7b Mon Sep 17 00:00:00 2001 From: Sanaei Date: Sun, 13 Sep 2026 22:44:50 +0200 Subject: [PATCH] fix(node): import a newly selected node inbound instead of sweeping it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saving the node form writes the grown selection and marks the node dirty in one transaction. On the next tick ReconcileNode runs before the snapshot merge, and its delete sweep treats a selected tag with no central row as "deleted on the master" — so an inbound the operator just ticked in the picker (or every unselected one, when switching the node to "all") is deleted from the node before the import that would have created its row ever runs. Nothing on disk separates "pending import" from "deleted while the node was unreachable", but the pre-adoption guard already expresses the former: while inbounds_adopted_at is zero the sweep waits for a clean sync to adopt. A save that grows the managed set now zeroes it again, and the same clean sync re-stamps it, so the offline-delete sweep is only deferred by one successful sync, not disabled. The trade: an inbound deleted on the master while the node was unreachable is re-imported instead of swept if the operator grows the node's selection during that same outage. That is visible and recoverable, where the previous behaviour destroyed a live inbound. Closes #6329 --- internal/database/model/model.go | 4 +- internal/web/service/inbound_node.go | 2 +- .../service/inbound_node_reconcile_test.go | 69 +++++++++++++++++++ internal/web/service/node.go | 24 +++++++ 4 files changed, 96 insertions(+), 3 deletions(-) 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