Skip to content

fix(outbounds): preserve stable subscription tags - #6345

Open
dawNotPoi wants to merge 1 commit into
MHSanaei:mainfrom
dawNotPoi:fix/6341-stable-subscription-tags
Open

fix(outbounds): preserve stable subscription tags#6345
dawNotPoi wants to merge 1 commit into
MHSanaei:mainfrom
dawNotPoi:fix/6341-stable-subscription-tags

Conversation

@dawNotPoi

Copy link
Copy Markdown
Contributor

Summary

  • Reserve historical tags for identities that are still present before assigning tags to new links.
  • Prevent positional fallback, fresh allocation, and collision suffixes from taking those reserved tags.
  • Add regression coverage for inserted links and fresh/suffix collisions.

Why

A newly inserted subscription link could reuse the positional tag of an existing server before that server was processed. The existing server was then assigned a suffixed tag, and the swapped identity-to-tag mapping was persisted across refreshes.

This could silently make an exact routing or balancer tag point to a different physical server.

Closes #6341

Existing subscriptions whose mapping was already corrupted cannot be repaired unambiguously and may still need to be removed and added again once.

Type of change

  • Bug fix
  • New feature
  • Refactoring (no behavior change)
  • Documentation
  • Tests only
  • Build / CI / tooling
  • Other

Areas affected

  • Frontend (UI / panel pages)
  • Backend (API endpoints, login, settings)
  • Xray config generation
  • Subscription (share links / Clash / JSON)
  • Statistics / traffic counters
  • Database / migrations
  • Install / upgrade script
  • Docker image
  • Multi-node (sub-nodes)
  • Telegram bot

How was this tested?

Regression coverage confirms:

  • a new link inserted before an existing identity cannot take its stable tag;
  • a fresh tag cannot collide with a stable tag;
  • suffix allocation skips multiple reserved tags;
  • a positional tag remains reusable after its former identity disappears.

Commands run:

  • go test -count=100 -run '^TestAssignStableTags$' ./internal/web/service
  • go test -race -shuffle=on -count=1 ./internal/web/service
  • go test ./internal/web/service -count=1
  • make lint
  • make verify
  • git diff --check

The inserted-link regression test was also confirmed to fail against the parent commit and pass with this change.

Screenshots / recordings

N/A — backend-only behavior change.

Breaking changes

None.

Checklist

  • I tested the change locally and confirmed the described behavior.
  • I added or updated tests for the new behavior (when applicable).
  • go build ./... and the test suite pass locally.
  • For frontend changes: npm run lint, npm run typecheck, and npm run build pass.
  • I updated the Wiki / README / API docs if user-facing behavior changed.
  • My commits follow the project's existing message style.
  • I have no unrelated changes mixed into this PR.

An inserted link could claim a previous positional tag before the existing identity that owned it was processed. The owner was then suffixed and the swapped mapping persisted across refreshes.

Reserve tags for identities still present in the batch so positional fallback, fresh allocation, and collision suffixes cannot take them.
@dawNotPoi
dawNotPoi marked this pull request as ready for review August 28, 2026 05:38
@github-actions

Copy link
Copy Markdown
Contributor

Code review

No blocking issues — 0 🔴 / 0 🟡 / 0 🟣

The fix is proportionate to the bug: a reservation pre-pass plus two guard conditions inside assignStableTags, no new state, no schema change, no new wire format.

I traced the algorithm rather than taking the tests' word for it, and it holds up:

  • The hole is actually closed. After the change, a tag in reservedStableTags can only be assigned via the final == identityTag escape at outbound_subscription.go#L503-L508, and identityTag is set only from prev[identities[i]] at L480-L484 — i.e. only by the tag's own owner. Both the positional path (L487) and the suffix path (L505) are blocked for everyone else.
  • The suffix loop still terminates and still emits unique tags. The body rebuilds final from candidate, not from the previous final, so the sequence is candidate-1, candidate-2, … with strictly increasing k; used[final] is evaluated unconditionally first, and the reserved clause is unsatisfiable for k >= 1 (since candidate == identityTag whenever identityTag != ""), so it cannot re-admit a rejected value.
  • No gratuitous re-tagging on upgrade. An identity present in prev with a non-empty tag deterministically gets that same tag regardless of reservedStableTags, so existing routing rules and balancer selectors keep pointing at the same server on the first refresh after upgrade. Only a non-owner that was about to steal a live tag gets a different result.
  • Edge cases checked: duplicate identities in the batch (the identity strips the remark, so this is reachable), an owner whose stable tag is itself of the form X-N, identityTag == "", prev == nil, and len(identities) != len(parsed) — the reservation loop uses the same i < len(identities) bound as the main loop, so the two cannot disagree. Behaviour in each is unchanged from pre-PR or strictly better.
  • The three new subtests each go red without the fix. Hand-evaluated against link.SuggestTag/SlugRemark: reverting the guards yields [sub1-poland, sub1-netherlands, sub1-netherlands-1], [sub1-netherlands, sub1-netherlands-1] and [sub1-netherlands, sub1-netherlands-1, sub1-netherlands-1-1] respectively — all three differ from the asserted values.

One thing worth carrying into the release note, already acknowledged in the PR description: an install whose LinkIdentities was corrupted before this upgrade keeps the wrong mapping, because both identities are then present and both tags reserved. The change stops recurrence; it does not repair existing state.


Coverage — head c309cd334b10fc36b27380a9a8cf62a6ac7de093, 2 files, +54/−3.

  • assignStableTags + its fetchAndStore caller (L386-L425): traced by hand for correctness, termination, uniqueness and upgrade stability — clean.
  • Persistence round-trip: newIdent is built from all parsed while LastFetchedOutbounds stores only kept from filterOutboundsRejectedByCore (L421-L436), so prevTagByIndex can be index-shifted. Pre-existing, and this PR makes that shift less harmful, not more.
  • Wire format: emitted Xray outbound tag values — unchanged for every identity with a prev entry.
  • CLAUDE.md: fix size, 2-line comment rule (no comment block added or modified), stdlib-testing/t.Run/exact-value asserts, "test must fail without its fix" — all satisfied. No route, model or i18n change, so the endpoints.ts chain and the migration rule do not apply.
  • Grepped frontend/src/lib/xray/ and docs/lib/xray/: outbound-subscription tag stability has no TS counterpart, so the three link implementations cannot diverge here.
  • CI on this head: all checks green, including go-test, race, golangci, postgres-durable-first, codegen and fuzz-smoke.
  • Not verified: this environment cannot build or run the PR checkout, so the test results above are reasoned from the code plus that green CI run, not executed locally. XRAY_E2E_BINARY and XUI_SCALE_TEST are set by no job — not material here, since assignStableTags is a pure string function with no gRPC or dialect dependency.

Reviewed head: c309cd3

kuzzrus added a commit to kuzzrus/3x-ui-awg that referenced this pull request Sep 1, 2026
A newly inserted subscription link could reuse the positional tag of an existing server before that server's own entry was processed later in the same refresh loop. The existing server was then bumped to a suffixed tag instead, silently repointing any routing or balancer rule that referenced the original stable tag at a different physical server.

Ported from upstream MHSanaei#6345. Reserves historical tags for identities still present in the current fetch before assigning tags to new/renamed links, so neither the positional-fallback path nor a fresh-tag collision suffix can steal a tag that rightfully belongs to another identity still in the batch.

Verified: all 8 TestAssignStableTags subtests pass with the fix; the 3 new ones confirmed to fail without it (reproduces the exact wrong-tag assignment). Reviewed by the repo's automated Claude Bot (verdict: Approve); one doc-comment suggestion applied, one pre-existing orthogonal bug (duplicate identities within a subscription) tracked separately per the bot's own recommendation not to bolt it onto this PR, one upgrade caveat noted in the PR description instead of a code change.

Upgrade note: this fix prevents the mis-assignment from recurring, it does not repair a subscription that already hit it -- see PR discussion for the recovery steps if a routing/balancer rule seems to be pointing at the wrong server on a subscription that predates this fix.

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Updating a subscription duplicates an existing server when new servers are added

1 participant