fix(node): never sweep a node's inbounds before their first adoption

Adding a node imports nothing; its pre-existing inbounds only become
central rows on the first clean traffic-sync tick. But any save of the
node (switching sync mode, picking tags after "Load inbounds from
node") marks it config-dirty, and the next tick then ran ReconcileNode
before that first adoption: with zero central rows the delete sweep saw
every remote tag as undesired and destroyed the node's real inbounds -
in "all" mode all of them - disconnecting live clients with no
confirmation, and the master then reported "record not found".

Track the first completed clean sync in nodes.inbounds_adopted_at and
skip the sweep (pushes still run) until it is set, so "absent locally"
can no longer be conflated with "deleted on the master". A node that
has synced before still sweeps normally, including the offline
last-inbound-deleted case. Existing nodes are seeded as adopted on
upgrade to keep their behavior unchanged.

Closes #5898
This commit is contained in:
MHSanaei
2026-07-11 21:30:21 +02:00
parent fc625d8f66
commit 200ea09157
6 changed files with 67 additions and 1 deletions
+18 -1
View File
@@ -928,7 +928,7 @@ func runSeeders(isUsersEmpty bool) error {
}
if empty && isUsersEmpty {
seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "ApiTokensHash", "LegacyProxySettingsCleanup", "WireguardPeersToClients", "MtprotoSecretsToClients"}
seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "ApiTokensHash", "LegacyProxySettingsCleanup", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted"}
for _, name := range seeders {
if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
return err
@@ -1021,6 +1021,12 @@ func runSeeders(isUsersEmpty bool) error {
}
}
if !slices.Contains(seedersHistory, "NodeInboundsAdopted") {
if err := seedNodeInboundsAdopted(); err != nil {
return err
}
}
if err := seedHostsFromExternalProxy(); err != nil {
return err
}
@@ -1055,6 +1061,17 @@ func runSeeders(isUsersEmpty bool) error {
return normalizeSettingPaths()
}
// seedNodeInboundsAdopted keeps the pre-existing reconcile behavior for nodes
// that were already syncing before the inbounds_adopted_at gate was introduced.
func seedNodeInboundsAdopted() error {
if err := db.Model(&model.Node{}).
Where("inbounds_adopted_at = 0").
Update("inbounds_adopted_at", time.Now().Unix()).Error; err != nil {
return err
}
return db.Create(&model.HistoryOfSeeders{SeederName: "NodeInboundsAdopted"}).Error
}
func seedHostGroupIds() error {
var history []string
if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &history).Error; err != nil {
+4
View File
@@ -719,6 +719,10 @@ 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 int64 `json:"-" gorm:"column:inbounds_adopted_at;default:0"`
InboundCount int `json:"inboundCount" gorm:"-" example:"5"`
ClientCount int `json:"clientCount" gorm:"-" example:"27"`
OnlineCount int `json:"onlineCount" gorm:"-" example:"3"`
@@ -399,6 +399,11 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSy
if changed {
j.structural.set()
}
if !dirty && n.InboundsAdoptedAt == 0 {
if markErr := j.nodeService.MarkNodeInboundsAdopted(n.Id); markErr != nil {
logger.Warningf("node traffic sync: mark inbounds adopted for %s failed: %v", n.Name, markErr)
}
}
active := make([]string, 0, len(snap.OnlineEmails))
active = append(active, snap.OnlineEmails...)
+5
View File
@@ -129,6 +129,11 @@ 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"
// means "not imported yet" — sweeping now would wipe the node at onboarding.
if n.InboundsAdoptedAt == 0 {
return errors.Join(errs...)
}
// In "selected" sync mode the panel only manages the selected tags: the
// rest were never imported, so their absence from the local DB must not
// delete them from the node. Only a selected tag missing locally (the
@@ -87,6 +87,7 @@ func reconcileTestNode(t *testing.T, ts *httptest.Server, name, mode string, tag
Status: "online",
InboundSyncMode: mode,
InboundTags: tags,
InboundsAdoptedAt: 1,
}
if err := database.GetDB().Create(n).Error; err != nil {
t.Fatalf("create node: %v", err)
@@ -143,6 +144,31 @@ func TestReconcileNode_AllModeDeletesUndesiredRemoteInbounds(t *testing.T) {
}
}
// A node whose pre-existing inbounds were never adopted into the central DB
// has zero local rows for legitimate reasons: reconcile before that first
// adoption must not sweep — it would delete every real inbound on the node
// right after onboarding (add node, save it again, watch it get wiped).
func TestReconcileNode_SkipsSweepBeforeFirstAdoption(t *testing.T) {
setupConflictDB(t)
ts, deletedIDs := fakeNodePanel(t, map[string]int{
"real-a": 1,
"real-b": 2,
"real-c": 3,
})
node := reconcileTestNode(t, ts, "fresh-node", "all", nil)
node.InboundsAdoptedAt = 0
svc := InboundService{}
if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(node, nil), node); err != nil {
t.Fatalf("ReconcileNode: %v", err)
}
if got := deletedIDs(); len(got) != 0 {
t.Fatalf("deleted remote ids = %v, want none before first adoption", got)
}
}
// One inbound the node rejects (e.g. a legacy protocol failing the node's
// request validation, #5685) must not abort the reconcile: the healthy inbound
// is still pushed, the delete sweep still runs, and the returned error names
+9
View File
@@ -771,6 +771,15 @@ func (s *NodeService) ClearNodeDirty(id int, dirtyAt int64) error {
Update("config_dirty", false).Error
}
func (s *NodeService) MarkNodeInboundsAdopted(id int) error {
if id <= 0 {
return nil
}
return database.GetDB().Model(model.Node{}).
Where("id = ? AND inbounds_adopted_at = 0", id).
Update("inbounds_adopted_at", time.Now().Unix()).Error
}
func (s *NodeService) NodeSyncState(id int) (enabled bool, status string, dirty bool, dirtyAt int64, err error) {
if id <= 0 {
return false, "", false, 0, errors.New("invalid node id")