From c9e62451e6238e85fbd037fe61452a3ce39a32cf Mon Sep 17 00:00:00 2001 From: Sanaei Date: Tue, 15 Sep 2026 22:39:59 +0200 Subject: [PATCH] fix(outbounds): keep subscription tags on their server when reality params rotate A subscription outbound's tag must stay bound to the upstream server it was assigned to for as long as that server stays in the subscription; balancers and routing rules select by that tag. The identity used to recognise a server across refreshes included every query parameter. A 3x-ui upstream picks a random shortId and SNI of a reality inbound on every request (older releases a random spiderX too), so no reality link was ever recognised, the stable-tag reservation never engaged, and every tag was handed out by list position. Removing or inserting a server then re-pointed existing tags at other servers: sub-germany carried France, sub-sweden Germany, and Sweden became sub-sweden-1. The identity now ignores sid, sni and spx when security=reality, since none of them selects the server. TLS sni still counts: it can pick the backend behind a shared front. Two more paths broke the same rule: - A link repeated in one body (same identity, different remark) shared a single link_identities key, so both tags gained a -N suffix on every refresh. Repeats are now numbered. - Links the core rejects were dropped after tagging, so the stored list that drives positional reuse was shorter than the parsed one and a rotated server behind a dropped link took its neighbour's tag. The filter now runs first; a dropped link's warning names its remark instead of a tag it never used. A mapping an older build already swapped stays swapped: its stored identities no longer match, so positional reuse reproduces it. Deleting and re-adding the subscription reallocates the tags from the remarks. Closes #6556 --- internal/util/link/outbound.go | 17 ++- internal/util/link/outbound_test.go | 11 ++ internal/web/service/outbound_subscription.go | 24 +++- .../web/service/outbound_subscription_test.go | 113 ++++++++++++++++++ 4 files changed, 158 insertions(+), 7 deletions(-) diff --git a/internal/util/link/outbound.go b/internal/util/link/outbound.go index 21bd04209..d3fbd6def 100644 --- a/internal/util/link/outbound.go +++ b/internal/util/link/outbound.go @@ -48,6 +48,7 @@ func ParseSubscriptionBody(body []byte) ([]Outbound, []string, error) { lines := splitLines(text) var outbounds []Outbound var identities []string + seen := map[string]int{} for _, ln := range lines { ln = strings.TrimSpace(ln) @@ -59,8 +60,14 @@ func ParseSubscriptionBody(body []byte) ([]Outbound, []string, error) { // Ignore unparseable lines (comments, unsupported protocols, etc.) continue } + identity := res.Identity + // A repeated identity would share one stored tag, shifting both tags on every refresh. + if n := seen[res.Identity]; n > 0 { + identity = fmt.Sprintf("%s#%d", res.Identity, n) + } + seen[res.Identity]++ outbounds = append(outbounds, res.Outbound) - identities = append(identities, res.Identity) + identities = append(identities, identity) } return outbounds, identities, nil } @@ -1047,10 +1054,18 @@ func firstParam(p url.Values, keys ...string) string { return "" } +// realityPerRequestParams are picked per request by subscription servers (3x-ui randomizes +// sid/sni, older releases spx too), so they must not split one server into new identities. +var realityPerRequestParams = map[string]bool{"sid": true, "sni": true, "spx": true} + func canonicalQuery(p url.Values) string { // Sort keys for stable identity + reality := p.Get("security") == "reality" keys := make([]string, 0, len(p)) for k := range p { + if reality && realityPerRequestParams[k] { + continue + } keys = append(keys, k) } // simple sort diff --git a/internal/util/link/outbound_test.go b/internal/util/link/outbound_test.go index a6fb3c496..f81d1be28 100644 --- a/internal/util/link/outbound_test.go +++ b/internal/util/link/outbound_test.go @@ -24,6 +24,17 @@ func TestParseVmessLink(t *testing.T) { } } +func TestLinkIdentityKeepsTLSServerName(t *testing.T) { + a, errA := ParseLink("vless://uuid@1.2.3.4:443?type=ws&security=tls&sni=a.example.com#node") + b, errB := ParseLink("vless://uuid@1.2.3.4:443?type=ws&security=tls&sni=b.example.com#node") + if errA != nil || errB != nil { + t.Fatalf("parse vless: %v, %v", errA, errB) + } + if a.Identity == b.Identity { + t.Fatalf("TLS links for different SNIs share identity %q", a.Identity) + } +} + func TestParseVlessLink(t *testing.T) { link := "vless://uuid@1.2.3.4:443?type=ws&security=tls&path=/&host=ex.com#node1" res, err := ParseLink(link) diff --git a/internal/web/service/outbound_subscription.go b/internal/web/service/outbound_subscription.go index 0bf3cec99..5384909bb 100644 --- a/internal/web/service/outbound_subscription.go +++ b/internal/web/service/outbound_subscription.go @@ -419,24 +419,36 @@ func (s *OutboundSubscriptionService) fetchAndStore(sub *model.OutboundSubscript } } + // Drop core-rejected links before tagging: prevTagByIndex indexes the persisted + // (filtered) list, so positions must be counted in that same list. + var droppedByCore []string + keptLinks, keptIdentities := parsed[:0], identities[:0] + for i, ob := range parsed { + if _, dropped := filterOutboundsRejectedByCore(fmt.Sprintf("outbound sub %d", sub.Id), []any{map[string]any(ob)}); len(dropped) > 0 { + droppedByCore = append(droppedByCore, dropped...) + continue + } + keptLinks = append(keptLinks, ob) + keptIdentities = append(keptIdentities, identities[i]) + } + // Assign tags with stability (identity reuse, positional fallback, then a // fresh allocation), keeping tags unique within this batch. Extracted into a // pure function so it can be unit-tested without network/DB. Tags are written // back into the parsed outbounds in place. - assigned := assignStableTags(parsed, identities, prev, prevTagByIndex, sub.Id, sub.TagPrefix) + assigned := assignStableTags(keptLinks, keptIdentities, prev, prevTagByIndex, sub.Id, sub.TagPrefix) // Persist identities for next time newIdent := map[string]string{} - for i, id := range identities { + for i, id := range keptIdentities { newIdent[id] = assigned[i] } identJSON, _ := json.Marshal(newIdent) - asAny := make([]any, len(parsed)) - for i := range parsed { - asAny[i] = map[string]any(parsed[i]) + kept := make([]any, len(keptLinks)) + for i := range keptLinks { + kept[i] = map[string]any(keptLinks[i]) } - kept, droppedByCore := filterOutboundsRejectedByCore(fmt.Sprintf("outbound sub %d", sub.Id), asAny) // Persist the outbounds (as compact JSON array) obsJSON, _ := json.Marshal(kept) diff --git a/internal/web/service/outbound_subscription_test.go b/internal/web/service/outbound_subscription_test.go index 014a5aa62..3054ce86f 100644 --- a/internal/web/service/outbound_subscription_test.go +++ b/internal/web/service/outbound_subscription_test.go @@ -2,10 +2,14 @@ package service import ( "bytes" + "encoding/base64" "errors" + "fmt" + "maps" "net/http" "net/http/httptest" "slices" + "strings" "testing" "gorm.io/gorm" @@ -128,6 +132,115 @@ func TestOutboundSubscriptionRefreshUsesCustomUserAgent(t *testing.T) { } } +// serveOutboundSubscription seeds a subscription whose URL returns body(n) for the n-th fetch. +func serveOutboundSubscription(t *testing.T, tagPrefix string, body func(n int) string) int { + t.Helper() + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + _, _ = w.Write([]byte(body(requests))) + })) + t.Cleanup(server.Close) + sub := &model.OutboundSubscription{Url: server.URL, AllowPrivate: true, TagPrefix: tagPrefix} + if err := database.GetDB().Create(sub).Error; err != nil { + t.Fatalf("seed subscription: %v", err) + } + return sub.Id +} + +func refreshOutboundTags(t *testing.T, subID int) (tags []string, byAddress map[string]string) { + t.Helper() + obs, err := (&OutboundSubscriptionService{}).Refresh(subID) + if err != nil { + t.Fatalf("Refresh: %v", err) + } + byAddress = map[string]string{} + for _, ob := range obs { + m := ob.(map[string]any) + tag, _ := m["tag"].(string) + address, _ := m["settings"].(map[string]any)["address"].(string) + tags = append(tags, tag) + byAddress[address] = tag + } + return tags, byAddress +} + +func TestOutboundSubscriptionRefreshKeepsTagsWhenRealityParamsRotate(t *testing.T) { + setupSettingTestDB(t) + pbk := base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32)) + type server struct{ remark, address string } + var servers []server + // A 3x-ui upstream picks sid and sni at random per request, and older releases spx too (#6556). + subID := serveOutboundSubscription(t, "sub", func(n int) string { + lines := make([]string, 0, len(servers)) + for _, s := range servers { + lines = append(lines, fmt.Sprintf( + "vless://00000000-0000-4000-8000-000000000000@%s:443?type=tcp&security=reality&pbk=%s&fp=chrome&sni=sni%d.example.com&sid=%02x&spx=%%2F%d#%s", + s.address, pbk, n, n, n, s.remark)) + } + return strings.Join(lines, "\n") + }) + + steps := []struct { + name string + servers []server + want map[string]string + }{ + { + "initial fetch", + []server{{"France", "1.1.1.1"}, {"Germany", "8.8.8.8"}, {"Sweden", "9.9.9.9"}}, + map[string]string{"1.1.1.1": "sub-france", "8.8.8.8": "sub-germany", "9.9.9.9": "sub-sweden"}, + }, + { + "France removed", + []server{{"Germany", "8.8.8.8"}, {"Sweden", "9.9.9.9"}}, + map[string]string{"8.8.8.8": "sub-germany", "9.9.9.9": "sub-sweden"}, + }, + { + "new France added first", + []server{{"France", "1.0.0.1"}, {"Germany", "8.8.8.8"}, {"Sweden", "9.9.9.9"}}, + map[string]string{"1.0.0.1": "sub-france", "8.8.8.8": "sub-germany", "9.9.9.9": "sub-sweden"}, + }, + } + for _, step := range steps { + servers = step.servers + if _, got := refreshOutboundTags(t, subID); !maps.Equal(got, step.want) { + t.Fatalf("%s: tags by address = %v, want %v", step.name, got, step.want) + } + } +} + +func TestOutboundSubscriptionRefreshKeepsTagsOfRepeatedLink(t *testing.T) { + setupSettingTestDB(t) + const link = "vless://00000000-0000-4000-8000-000000000000@1.1.1.1:443?security=tls&type=tcp" + subID := serveOutboundSubscription(t, "p-", func(int) string { return link + "#A\n" + link + "#B" }) + + want := []string{"p-a", "p-b"} + for refresh := 1; refresh <= 3; refresh++ { + if got, _ := refreshOutboundTags(t, subID); !slices.Equal(got, want) { + t.Fatalf("refresh %d: tags = %v, want %v", refresh, got, want) + } + } +} + +func TestOutboundSubscriptionRefreshAlignsPositionsPastCoreRejectedLink(t *testing.T) { + setupSettingTestDB(t) + // The unencrypted first link is dropped by the core; B and C then rotate their UUID. + subID := serveOutboundSubscription(t, "p-", func(n int) string { + uuid := fmt.Sprintf("00000000-0000-4000-8000-%012d", n) + return "vless://00000000-0000-4000-8000-000000000000@1.1.1.1:443?security=none&type=tcp#Plain\n" + + "vless://" + uuid + "@8.8.8.8:443?security=tls&type=tcp#B\n" + + "vless://" + uuid + "@9.9.9.9:443?security=tls&type=tcp#C" + }) + + want := map[string]string{"8.8.8.8": "p-b", "9.9.9.9": "p-c"} + for refresh := 1; refresh <= 2; refresh++ { + if _, got := refreshOutboundTags(t, subID); !maps.Equal(got, want) { + t.Fatalf("refresh %d: tags by address = %v, want %v", refresh, got, want) + } + } +} + func TestReadBoundedOutboundSubscriptionBody(t *testing.T) { t.Run("accepts body at the limit", func(t *testing.T) { want := bytes.Repeat([]byte("a"), int(maxOutboundSubscriptionBytes))