Ver Fonte

fix(node): import a newly selected node inbound instead of sweeping it

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
Sanaei há 17 horas atrás
pai
commit
22346eef78

+ 2 - 2
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"`

+ 1 - 1
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...)

+ 69 - 0
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)
+			}
+		})
+	}
+}

+ 24 - 0
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