mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-16 15:17:14 +00:00
c9e62451e6
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
456 lines
16 KiB
Go
456 lines
16 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"maps"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/util/link"
|
|
)
|
|
|
|
func TestOutboundSubscriptionCreatePropagatesAllocationDatabaseFailures(t *testing.T) {
|
|
setupSettingTestDB(t)
|
|
db := database.GetDB()
|
|
const callback = "test:fail_outbound_subscription_query"
|
|
errInjected := errors.New("injected outbound subscription query failure")
|
|
if err := db.Callback().Query().Before("gorm:query").Register(callback, func(tx *gorm.DB) {
|
|
if tx.Statement != nil && tx.Statement.Table == "outbound_subscriptions" {
|
|
tx.AddError(errInjected)
|
|
}
|
|
}); err != nil {
|
|
t.Fatalf("register query callback: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if err := db.Callback().Query().Remove(callback); err != nil {
|
|
t.Errorf("remove query callback: %v", err)
|
|
}
|
|
})
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
tagPrefix string
|
|
operation string
|
|
}{
|
|
{name: "default prefix query", tagPrefix: "", operation: "prefix allocation"},
|
|
{name: "priority count query", tagPrefix: "custom-", operation: "priority allocation"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
created, err := (&OutboundSubscriptionService{}).Create("test", "https://1.1.1.1/sub", tc.tagPrefix, "", true, 600, false, false, false)
|
|
if !errors.Is(err, errInjected) {
|
|
t.Fatalf("Create error = %v, want injected %s query failure", err, tc.operation)
|
|
}
|
|
if created != nil {
|
|
t.Fatalf("Create returned row %+v after %s query failure", created, tc.operation)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOutboundSubscriptionUpdatePropagatesPrefixQueryFailureWithoutMutation(t *testing.T) {
|
|
setupSettingTestDB(t)
|
|
db := database.GetDB()
|
|
original := &model.OutboundSubscription{
|
|
Remark: "before", Url: "https://1.1.1.1/original", TagPrefix: "custom-",
|
|
Enabled: true, UpdateInterval: 600,
|
|
}
|
|
if err := db.Create(original).Error; err != nil {
|
|
t.Fatalf("seed subscription: %v", err)
|
|
}
|
|
|
|
errInjected := errors.New("injected update prefix query failure")
|
|
queryCount := 0
|
|
const callback = "test:fail_update_prefix_query"
|
|
if err := db.Callback().Query().Before("gorm:query").Register(callback, func(tx *gorm.DB) {
|
|
if tx.Statement == nil || tx.Statement.Table != "outbound_subscriptions" {
|
|
return
|
|
}
|
|
queryCount++
|
|
if queryCount == 2 {
|
|
tx.AddError(errInjected)
|
|
}
|
|
}); err != nil {
|
|
t.Fatalf("register query callback: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if err := db.Callback().Query().Remove(callback); err != nil {
|
|
t.Errorf("remove query callback: %v", err)
|
|
}
|
|
})
|
|
|
|
err := (&OutboundSubscriptionService{}).Update(
|
|
original.Id, "after", "https://1.1.1.1/changed", "", "", false, 1200, false, false, false,
|
|
)
|
|
if !errors.Is(err, errInjected) {
|
|
t.Fatalf("Update error = %v, want injected prefix query failure", err)
|
|
}
|
|
if queryCount != 2 {
|
|
t.Fatalf("outbound subscription queries = %d, want Get plus prefix allocation", queryCount)
|
|
}
|
|
|
|
var got model.OutboundSubscription
|
|
if err := db.First(&got, original.Id).Error; err != nil {
|
|
t.Fatalf("reload subscription: %v", err)
|
|
}
|
|
if got.Remark != original.Remark || got.Url != original.Url || got.TagPrefix != original.TagPrefix ||
|
|
got.Enabled != original.Enabled || got.UpdateInterval != original.UpdateInterval {
|
|
t.Fatalf("subscription changed after failed allocation: got %+v, want %+v", got, *original)
|
|
}
|
|
}
|
|
|
|
func TestOutboundSubscriptionRefreshUsesCustomUserAgent(t *testing.T) {
|
|
setupSettingTestDB(t)
|
|
const wantUserAgent = "ClashMetaForAndroid/2.11.13"
|
|
var gotUserAgent string
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotUserAgent = r.UserAgent()
|
|
_, _ = w.Write([]byte("vless://00000000-0000-4000-8000-000000000000@1.1.1.1:443?security=tls&type=tcp#node"))
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
|
|
sub := &model.OutboundSubscription{
|
|
Url: server.URL, AllowPrivate: true, UserAgent: wantUserAgent, TagPrefix: "test-",
|
|
}
|
|
if err := database.GetDB().Create(sub).Error; err != nil {
|
|
t.Fatalf("seed subscription: %v", err)
|
|
}
|
|
if _, err := (&OutboundSubscriptionService{}).Refresh(sub.Id); err != nil {
|
|
t.Fatalf("Refresh: %v", err)
|
|
}
|
|
if gotUserAgent != wantUserAgent {
|
|
t.Fatalf("User-Agent = %q, want %q", gotUserAgent, wantUserAgent)
|
|
}
|
|
}
|
|
|
|
// 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))
|
|
got, err := readBoundedOutboundSubscriptionBody(bytes.NewReader(want))
|
|
if err != nil {
|
|
t.Fatalf("readBoundedOutboundSubscriptionBody: %v", err)
|
|
}
|
|
if !bytes.Equal(got, want) {
|
|
t.Fatalf("body mismatch: got %d bytes, want %d", len(got), len(want))
|
|
}
|
|
})
|
|
|
|
t.Run("rejects body over the limit", func(t *testing.T) {
|
|
body := bytes.Repeat([]byte("b"), int(maxOutboundSubscriptionBytes)+1)
|
|
got, err := readBoundedOutboundSubscriptionBody(bytes.NewReader(body))
|
|
if !errors.Is(err, errOutboundSubscriptionBodyTooLarge) {
|
|
t.Fatalf("error = %v, want errOutboundSubscriptionBodyTooLarge", err)
|
|
}
|
|
if got != nil {
|
|
t.Fatalf("oversized body returned %d bytes, want nil", len(got))
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestDefaultPrefixNumber(t *testing.T) {
|
|
mk := func(id int, prefix string) *model.OutboundSubscription {
|
|
return &model.OutboundSubscription{Id: id, TagPrefix: prefix}
|
|
}
|
|
cases := []struct {
|
|
name string
|
|
subs []*model.OutboundSubscription
|
|
excludeId int
|
|
want int
|
|
}{
|
|
{"no subscriptions starts at 1", nil, 0, 1},
|
|
{"sequential prefixes give the next", []*model.OutboundSubscription{mk(1, "sub1-"), mk(2, "sub2-")}, 0, 3},
|
|
{"reuses the lowest freed number", []*model.OutboundSubscription{mk(2, "sub2-")}, 0, 1},
|
|
{"legacy blank prefix reserves its id", []*model.OutboundSubscription{mk(1, ""), mk(5, "sub3-")}, 0, 2},
|
|
{"custom prefixes are ignored", []*model.OutboundSubscription{mk(1, "hk-"), mk(2, "jp-")}, 0, 1},
|
|
{"excludes the edited subscription", []*model.OutboundSubscription{mk(5, "sub2-")}, 5, 1},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
if got := defaultPrefixNumber(c.subs, c.excludeId); got != c.want {
|
|
t.Fatalf("got %d, want %d", got, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAssignStableTags(t *testing.T) {
|
|
t.Run("reuses the tag mapped to a known identity", func(t *testing.T) {
|
|
parsed := []link.Outbound{{"tag": "JP-Tokyo"}}
|
|
prev := map[string]string{"id-abc": "sub1-keepme"}
|
|
got := assignStableTags(parsed, []string{"id-abc"}, prev, nil, 1, "")
|
|
if got[0] != "sub1-keepme" {
|
|
t.Fatalf("got %q, want sub1-keepme", got[0])
|
|
}
|
|
if parsed[0]["tag"] != "sub1-keepme" {
|
|
t.Fatalf("tag was not written back into the outbound: %v", parsed[0]["tag"])
|
|
}
|
|
})
|
|
|
|
t.Run("falls back to the previous tag at the same position", func(t *testing.T) {
|
|
parsed := []link.Outbound{{"tag": "JP-Tokyo"}}
|
|
prev := map[string]string{"id-gone": "sub1-oldpos"}
|
|
got := assignStableTags(parsed, []string{"id-new"}, prev, map[int]string{0: "sub1-oldpos"}, 1, "")
|
|
if got[0] != "sub1-oldpos" {
|
|
t.Fatalf("got %q, want sub1-oldpos", got[0])
|
|
}
|
|
})
|
|
|
|
t.Run("does not let an inserted link steal a stable tag", func(t *testing.T) {
|
|
parsed := []link.Outbound{{"tag": "Poland"}, {"tag": "NewServer"}, {"tag": "Netherlands"}}
|
|
prev := map[string]string{
|
|
"id-poland": "sub1-poland",
|
|
"id-netherlands": "sub1-netherlands",
|
|
}
|
|
prevTagByIndex := map[int]string{0: "sub1-poland", 1: "sub1-netherlands"}
|
|
|
|
got := assignStableTags(parsed, []string{"id-poland", "id-new", "id-netherlands"}, prev, prevTagByIndex, 1, "")
|
|
want := []string{"sub1-poland", "sub1-newserver", "sub1-netherlands"}
|
|
if !slices.Equal(got, want) {
|
|
t.Fatalf("got %v, want %v", got, want)
|
|
}
|
|
})
|
|
|
|
t.Run("does not let a fresh tag steal a stable tag", func(t *testing.T) {
|
|
parsed := []link.Outbound{{"tag": "Netherlands"}, {"tag": "Renamed"}}
|
|
prev := map[string]string{"id-netherlands": "sub1-netherlands"}
|
|
|
|
got := assignStableTags(parsed, []string{"id-new", "id-netherlands"}, prev, nil, 1, "")
|
|
want := []string{"sub1-netherlands-1", "sub1-netherlands"}
|
|
if !slices.Equal(got, want) {
|
|
t.Fatalf("got %v, want %v", got, want)
|
|
}
|
|
})
|
|
|
|
t.Run("skips reserved tags while adding a suffix", func(t *testing.T) {
|
|
parsed := []link.Outbound{{"tag": "Netherlands"}, {"tag": "First"}, {"tag": "Second"}}
|
|
prev := map[string]string{
|
|
"id-first": "sub1-netherlands",
|
|
"id-second": "sub1-netherlands-1",
|
|
}
|
|
|
|
got := assignStableTags(parsed, []string{"id-new", "id-first", "id-second"}, prev, nil, 1, "")
|
|
want := []string{"sub1-netherlands-2", "sub1-netherlands", "sub1-netherlands-1"}
|
|
if !slices.Equal(got, want) {
|
|
t.Fatalf("got %v, want %v", got, want)
|
|
}
|
|
})
|
|
|
|
t.Run("allocates a fresh tag with the default sub<id>- prefix", func(t *testing.T) {
|
|
parsed := []link.Outbound{{"tag": "Tokyo"}}
|
|
got := assignStableTags(parsed, []string{"id-x"}, nil, nil, 7, "")
|
|
want := link.SuggestTag("sub7-", "Tokyo", 0)
|
|
if got[0] != want {
|
|
t.Fatalf("got %q, want %q", got[0], want)
|
|
}
|
|
})
|
|
|
|
t.Run("uses a custom prefix for fresh tags", func(t *testing.T) {
|
|
parsed := []link.Outbound{{"tag": "Tokyo"}}
|
|
got := assignStableTags(parsed, []string{"id-x"}, nil, nil, 1, "hk-")
|
|
want := link.SuggestTag("hk-", "Tokyo", 0)
|
|
if got[0] != want {
|
|
t.Fatalf("got %q, want %q", got[0], want)
|
|
}
|
|
})
|
|
|
|
t.Run("disambiguates colliding tags with a -N suffix", func(t *testing.T) {
|
|
parsed := []link.Outbound{{"tag": "Same"}, {"tag": "Same"}}
|
|
got := assignStableTags(parsed, []string{"id1", "id2"}, nil, nil, 1, "p-")
|
|
base := link.SuggestTag("p-", "Same", 0)
|
|
if got[0] != base {
|
|
t.Fatalf("got[0] = %q, want %q", got[0], base)
|
|
}
|
|
if got[1] != base+"-1" {
|
|
t.Fatalf("got[1] = %q, want %q", got[1], base+"-1")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestOutboundsContainTag covers the guard that ensures the outbound under test
|
|
// is present in the HTTP-probe config. Subscription outbounds aren't part of the
|
|
// template outbounds the frontend sends as allOutbounds, so the probe must append
|
|
// the tested outbound when its tag is missing (otherwise burstObservatory has
|
|
// nothing to probe and every subscription test times out).
|
|
func TestOutboundsContainTag(t *testing.T) {
|
|
template := []any{
|
|
map[string]any{"tag": "direct", "protocol": "freedom"},
|
|
map[string]any{"tag": "blocked", "protocol": "blackhole"},
|
|
}
|
|
if !outboundsContainTag(template, "direct") {
|
|
t.Fatal("expected tag 'direct' to be found")
|
|
}
|
|
if outboundsContainTag(template, "sub1-tokyo") {
|
|
t.Fatal("expected subscription tag to be absent from template outbounds")
|
|
}
|
|
if outboundsContainTag(nil, "anything") {
|
|
t.Fatal("expected empty slice to contain no tags")
|
|
}
|
|
// Tolerates non-map / untagged entries without panicking.
|
|
mixed := []any{"not-a-map", map[string]any{"protocol": "freedom"}}
|
|
if outboundsContainTag(mixed, "direct") {
|
|
t.Fatal("expected no match among untagged/non-map entries")
|
|
}
|
|
}
|
|
|
|
// TestSanitizePublicHTTPURLRejectsPrivateAndBadSchemes covers the SSRF guard used
|
|
// when fetching subscription URLs. All rejected cases use literal IPs or bad
|
|
// schemes so the test never performs real DNS resolution.
|
|
func TestSanitizePublicHTTPURLRejectsPrivateAndBadSchemes(t *testing.T) {
|
|
rejected := []string{
|
|
"http://127.0.0.1/sub", // loopback
|
|
"http://10.0.0.1/x", // private
|
|
"http://192.168.1.1", // private
|
|
"http://169.254.169.254/latest/meta-data", // link-local (cloud metadata)
|
|
"http://[::1]:8080/sub", // IPv6 loopback
|
|
"http://0.0.0.0", // unspecified
|
|
"ftp://example.com/x", // unsupported scheme
|
|
"file:///etc/passwd", // unsupported scheme
|
|
}
|
|
for _, raw := range rejected {
|
|
if _, err := SanitizePublicHTTPURL(raw, false); err == nil {
|
|
t.Errorf("expected %q to be rejected, got nil error", raw)
|
|
}
|
|
}
|
|
|
|
t.Run("allows a public literal IP without DNS", func(t *testing.T) {
|
|
got, err := SanitizePublicHTTPURL("http://8.8.8.8/sub", false)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got != "http://8.8.8.8/sub" {
|
|
t.Fatalf("got %q, want http://8.8.8.8/sub", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
// outboundsContainTag mirrors the small helper in the outbound subpackage so
|
|
// these subscription tests can assert on tag presence without importing it.
|
|
func outboundsContainTag(outbounds []any, tag string) bool {
|
|
for _, ob := range outbounds {
|
|
if m, ok := ob.(map[string]any); ok {
|
|
if t, _ := m["tag"].(string); t == tag {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|