From 6f40a51d62b33f4fdefcadab0f3f82484d56cc85 Mon Sep 17 00:00:00 2001 From: Sanaei Date: Fri, 4 Sep 2026 02:34:53 +0200 Subject: [PATCH] fix(node): sweep a selected inbound the node reports without its prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In "selected" sync mode the reconcile sweep built its set of managed tags verbatim from node.InboundTags. A panel-created node inbound is stored with an n- prefix (composeInboundTag) and pushed to the node with that prefix stripped (wireInbound), so the tag the node reports never matched the set and the sweep skipped it. The effect is the case the sweep exists for: an operator deletes a node inbound while the node is offline, and the node keeps serving it — and its clients — indefinitely. Only unprefixed tags were unaffected, which is why the existing selected-mode test did not catch it. nodeSelectedTagSet already builds both tag forms for exactly this reason and is used by the snapshot filter; the sweep now uses it too, so the two agree. --- internal/web/service/inbound_node.go | 10 +++---- .../service/inbound_node_reconcile_test.go | 27 +++++++++++++++++++ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/internal/web/service/inbound_node.go b/internal/web/service/inbound_node.go index 775fa6773..74ce45f26 100644 --- a/internal/web/service/inbound_node.go +++ b/internal/web/service/inbound_node.go @@ -173,13 +173,9 @@ func (s *InboundService) ReconcileNode(ctx context.Context, rt *runtime.Remote, // 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 // panel deleted it while the node was unreachable) may be swept. - var selected map[string]struct{} - if n.InboundSyncMode == "selected" { - selected = make(map[string]struct{}, len(n.InboundTags)) - for _, tag := range n.InboundTags { - selected[tag] = struct{}{} - } - } + // The node reports a panel-created inbound with its n- prefix stripped, + // so the selected set must carry both forms or the sweep never matches. + selected := nodeSelectedTagSet(n) for _, tag := range remoteTags { if _, want := desiredTags[tag]; want { continue diff --git a/internal/web/service/inbound_node_reconcile_test.go b/internal/web/service/inbound_node_reconcile_test.go index 9817652f1..d10c69008 100644 --- a/internal/web/service/inbound_node_reconcile_test.go +++ b/internal/web/service/inbound_node_reconcile_test.go @@ -3,6 +3,7 @@ package service import ( "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" "net/url" @@ -404,3 +405,29 @@ func TestEnsureInboundTagAllowed(t *testing.T) { t.Fatalf("all-mode node must stay without tags, got %#v", gotAll.InboundTags) } } + +// A panel-created node inbound is stored as "n-tag" and pushed to the node +// with the prefix stripped, so the sweep's selected set must match both forms. +func TestReconcileNode_SelectedModeSweepsPrefixedSelectedTag(t *testing.T) { + setupConflictDB(t) + + ts, deletedIDs := fakeNodePanel(t, map[string]int{ + "keep": 1, + "selected-gone": 2, + "unmanaged": 3, + }) + node := reconcileTestNode(t, ts, "sel-prefix-node", "selected", nil) + prefix := fmt.Sprintf("n%d-", node.Id) + node.InboundTags = []string{prefix + "keep", prefix + "selected-gone"} + seedInboundConflictNode(t, prefix+"keep", "", 443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, &node.Id) + + svc := InboundService{} + if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(node, nil), node); err != nil { + t.Fatalf("ReconcileNode: %v", err) + } + + got := deletedIDs() + if len(got) != 1 || got[0] != 2 { + t.Fatalf("deleted remote ids = %v, want [2] (prefixed selected tag must be swept, unmanaged 3 must survive)", got) + } +}