mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-14 18:32:15 +03:00
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
This commit is contained in:
@@ -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"`
|
||||
|
||||
@@ -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...)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user