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