outbound_subscription.go 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "fmt"
  7. "io"
  8. "net/http"
  9. "regexp"
  10. "strconv"
  11. "strings"
  12. "time"
  13. "github.com/mhsanaei/3x-ui/v3/internal/database"
  14. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  15. "github.com/mhsanaei/3x-ui/v3/internal/logger"
  16. "github.com/mhsanaei/3x-ui/v3/internal/util/common"
  17. "github.com/mhsanaei/3x-ui/v3/internal/util/link"
  18. "github.com/mhsanaei/3x-ui/v3/internal/xray"
  19. )
  20. // filterOutboundsRejectedByCore drops outbounds the vendored xray-core config
  21. // loader refuses to build — since v26.7.11 that includes unencrypted
  22. // vless/trojan outbounds to public addresses — because one such outbound in
  23. // the merged config would keep the whole core from starting.
  24. func filterOutboundsRejectedByCore(label string, outbounds []any) ([]any, []string) {
  25. kept := make([]any, 0, len(outbounds))
  26. var dropped []string
  27. for _, ob := range outbounds {
  28. raw, err := json.Marshal(ob)
  29. if err == nil {
  30. if buildErr := xray.ValidateOutboundConfig(raw); buildErr != nil {
  31. tag := ""
  32. if m, ok := ob.(map[string]any); ok {
  33. tag, _ = m["tag"].(string)
  34. }
  35. logger.Warningf("%s: dropping outbound %q rejected by xray-core: %v", label, tag, buildErr)
  36. dropped = append(dropped, fmt.Sprintf("%s: %v", tag, buildErr))
  37. continue
  38. }
  39. }
  40. kept = append(kept, ob)
  41. }
  42. return kept, dropped
  43. }
  44. // maxOutboundSubscriptionBytes caps a single outbound subscription response.
  45. // It is larger than the 2 MiB user-facing subscription cap because an outbound
  46. // subscription may aggregate many upstream outbounds into one document.
  47. const maxOutboundSubscriptionBytes int64 = 8 << 20
  48. var errOutboundSubscriptionBodyTooLarge = errors.New("outbound subscription response body exceeds size limit")
  49. func readBoundedOutboundSubscriptionBody(r io.Reader) ([]byte, error) {
  50. body, err := io.ReadAll(io.LimitReader(r, maxOutboundSubscriptionBytes+1))
  51. if err != nil {
  52. return nil, err
  53. }
  54. if int64(len(body)) > maxOutboundSubscriptionBytes {
  55. return nil, fmt.Errorf("%w (limit: %d bytes)", errOutboundSubscriptionBodyTooLarge, maxOutboundSubscriptionBytes)
  56. }
  57. return body, nil
  58. }
  59. // OutboundSubscriptionService manages remote outbound subscriptions.
  60. type OutboundSubscriptionService struct {
  61. settingService SettingService
  62. }
  63. // NewOutboundSubscriptionService returns a service for managing outbound subscriptions.
  64. func NewOutboundSubscriptionService() *OutboundSubscriptionService {
  65. return &OutboundSubscriptionService{}
  66. }
  67. // List returns all subscriptions (newest first).
  68. func (s *OutboundSubscriptionService) List() ([]*model.OutboundSubscription, error) {
  69. db := database.GetDB()
  70. var subs []*model.OutboundSubscription
  71. if err := db.Model(&model.OutboundSubscription{}).Order("priority asc, id asc").Find(&subs).Error; err != nil {
  72. return nil, err
  73. }
  74. for _, sub := range subs {
  75. sub.OutboundCount = countOutbounds(sub.LastFetchedOutbounds)
  76. // Don't ship the heavy raw blobs to the list view.
  77. sub.LastFetchedOutbounds = ""
  78. sub.LinkIdentities = ""
  79. }
  80. return subs, nil
  81. }
  82. // countOutbounds returns the number of outbounds in a stored LastFetchedOutbounds
  83. // JSON array (0 for empty/invalid).
  84. func countOutbounds(raw string) int {
  85. if strings.TrimSpace(raw) == "" {
  86. return 0
  87. }
  88. var arr []any
  89. if json.Unmarshal([]byte(raw), &arr) != nil {
  90. return 0
  91. }
  92. return len(arr)
  93. }
  94. // Get returns a single subscription by id.
  95. func (s *OutboundSubscriptionService) Get(id int) (*model.OutboundSubscription, error) {
  96. db := database.GetDB()
  97. var sub model.OutboundSubscription
  98. if err := db.First(&sub, id).Error; err != nil {
  99. return nil, err
  100. }
  101. return &sub, nil
  102. }
  103. // Create persists a new subscription. It does not fetch immediately; the caller
  104. // can call Refresh on the returned id if desired.
  105. var defaultPrefixRe = regexp.MustCompile(`^sub(\d+)-$`)
  106. // defaultPrefixNumber returns the smallest positive integer N that is not already
  107. // in use as a "subN-" tag prefix among the given subscriptions. This is used to
  108. // auto-name a subscription's outbounds when the user leaves the prefix blank, so
  109. // deleting a subscription frees its number for reuse instead of letting the
  110. // number grow forever with the auto-increment DB id. A subscription with a blank
  111. // prefix reserves its own id (it falls back to id-based "sub<id>-" tags).
  112. func defaultPrefixNumber(subs []*model.OutboundSubscription, excludeId int) int {
  113. used := map[int]bool{}
  114. for _, sub := range subs {
  115. if sub.Id == excludeId {
  116. continue
  117. }
  118. if sub.TagPrefix == "" {
  119. used[sub.Id] = true
  120. continue
  121. }
  122. if m := defaultPrefixRe.FindStringSubmatch(sub.TagPrefix); m != nil {
  123. if n, err := strconv.Atoi(m[1]); err == nil {
  124. used[n] = true
  125. }
  126. }
  127. }
  128. n := 1
  129. for used[n] {
  130. n++
  131. }
  132. return n
  133. }
  134. // nextDefaultSubPrefix builds the default "subN-" prefix for a new/edited
  135. // subscription, picking the smallest free N (excludeId skips a subscription's
  136. // own current prefix when editing).
  137. func (s *OutboundSubscriptionService) nextDefaultSubPrefix(excludeId int) string {
  138. var subs []*model.OutboundSubscription
  139. _ = database.GetDB().Find(&subs).Error
  140. return fmt.Sprintf("sub%d-", defaultPrefixNumber(subs, excludeId))
  141. }
  142. func (s *OutboundSubscriptionService) Create(remark, rawURL, tagPrefix string, enabled bool, updateInterval int, allowPrivate, prepend bool) (*model.OutboundSubscription, error) {
  143. cleanURL, err := SanitizePublicHTTPURL(rawURL, allowPrivate)
  144. if err != nil {
  145. return nil, common.NewError("invalid subscription URL:", err)
  146. }
  147. if cleanURL == "" {
  148. return nil, common.NewError("subscription URL is required")
  149. }
  150. if updateInterval <= 0 {
  151. updateInterval = 600
  152. }
  153. prefix := strings.TrimSpace(tagPrefix)
  154. if prefix == "" {
  155. prefix = s.nextDefaultSubPrefix(0)
  156. }
  157. // New subscriptions go to the end of the priority order.
  158. var count int64
  159. database.GetDB().Model(&model.OutboundSubscription{}).Count(&count)
  160. sub := &model.OutboundSubscription{
  161. Remark: strings.TrimSpace(remark),
  162. Url: cleanURL,
  163. Enabled: enabled,
  164. AllowPrivate: allowPrivate,
  165. Prepend: prepend,
  166. Priority: int(count),
  167. TagPrefix: prefix,
  168. UpdateInterval: updateInterval,
  169. }
  170. if err := database.GetDB().Create(sub).Error; err != nil {
  171. return nil, err
  172. }
  173. return sub, nil
  174. }
  175. // Update updates editable fields.
  176. func (s *OutboundSubscriptionService) Update(id int, remark, rawURL, tagPrefix string, enabled bool, updateInterval int, allowPrivate, prepend bool) error {
  177. sub, err := s.Get(id)
  178. if err != nil {
  179. return err
  180. }
  181. cleanURL, err := SanitizePublicHTTPURL(rawURL, allowPrivate)
  182. if err != nil {
  183. return common.NewError("invalid subscription URL:", err)
  184. }
  185. if cleanURL == "" {
  186. return common.NewError("subscription URL is required")
  187. }
  188. if updateInterval <= 0 {
  189. updateInterval = 600
  190. }
  191. prefix := strings.TrimSpace(tagPrefix)
  192. if prefix == "" {
  193. prefix = s.nextDefaultSubPrefix(sub.Id)
  194. }
  195. sub.Remark = strings.TrimSpace(remark)
  196. sub.Url = cleanURL
  197. sub.Enabled = enabled
  198. sub.AllowPrivate = allowPrivate
  199. sub.Prepend = prepend
  200. sub.TagPrefix = prefix
  201. sub.UpdateInterval = updateInterval
  202. return database.GetDB().Save(sub).Error
  203. }
  204. // Delete removes a subscription.
  205. func (s *OutboundSubscriptionService) Delete(id int) error {
  206. return database.GetDB().Delete(&model.OutboundSubscription{}, id).Error
  207. }
  208. // GetLastOutbounds returns the last successfully fetched outbounds for a subscription
  209. // (as raw interface slice ready for JSON merge). Returns nil slice when none.
  210. func (s *OutboundSubscriptionService) GetLastOutbounds(id int) ([]any, error) {
  211. sub, err := s.Get(id)
  212. if err != nil {
  213. return nil, err
  214. }
  215. if strings.TrimSpace(sub.LastFetchedOutbounds) == "" {
  216. return nil, nil
  217. }
  218. var arr []any
  219. if err := json.Unmarshal([]byte(sub.LastFetchedOutbounds), &arr); err != nil {
  220. return nil, err
  221. }
  222. return arr, nil
  223. }
  224. // Refresh fetches the subscription URL, parses the links, assigns stable tags,
  225. // persists the results, and returns the generated outbounds.
  226. func (s *OutboundSubscriptionService) Refresh(id int) ([]any, error) {
  227. sub, err := s.Get(id)
  228. if err != nil {
  229. return nil, err
  230. }
  231. outbounds, err := s.fetchAndStore(sub)
  232. return outbounds, err
  233. }
  234. // RefreshAllEnabled fetches every enabled subscription whose due time has passed
  235. // (lastUpdated + updateInterval <= now). It returns the number of subscriptions
  236. // that were actually refreshed.
  237. func (s *OutboundSubscriptionService) RefreshAllEnabled() (int, error) {
  238. db := database.GetDB()
  239. var subs []*model.OutboundSubscription
  240. if err := db.Where("enabled = ?", true).Find(&subs).Error; err != nil {
  241. return 0, err
  242. }
  243. now := time.Now().Unix()
  244. refreshed := 0
  245. for _, sub := range subs {
  246. due := sub.LastUpdated + int64(sub.UpdateInterval)
  247. if sub.LastUpdated == 0 || due <= now {
  248. if _, err := s.fetchAndStore(sub); err != nil {
  249. logger.Warningf("outbound sub %d (%s) refresh failed: %v", sub.Id, sub.Remark, err)
  250. // continue with others
  251. } else {
  252. refreshed++
  253. }
  254. }
  255. }
  256. return refreshed, nil
  257. }
  258. // fetchAndStore does the actual network + parse + stability + persist work.
  259. func (s *OutboundSubscriptionService) fetchAndStore(sub *model.OutboundSubscription) ([]any, error) {
  260. // Re-sanitize on every fetch (handles legacy rows + defense in depth against
  261. // any direct DB tampering). Private targets are blocked unless this
  262. // subscription was explicitly created with AllowPrivate.
  263. cleanURL, err := SanitizePublicHTTPURL(sub.Url, sub.AllowPrivate)
  264. if err != nil {
  265. s.recordError(sub, err)
  266. return nil, err
  267. }
  268. if cleanURL == "" {
  269. return nil, common.NewError("subscription has no valid URL")
  270. }
  271. sub.Url = cleanURL // persist the cleaned version
  272. client := s.settingService.NewProxiedHTTPClient(30 * time.Second)
  273. // Re-validate every redirect hop: the initial host is checked above, but a
  274. // redirect could still point at a private/internal address (SSRF). Cap the
  275. // redirect chain as well.
  276. client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
  277. if len(via) >= 10 {
  278. return fmt.Errorf("stopped after 10 redirects")
  279. }
  280. if sub.AllowPrivate {
  281. return nil
  282. }
  283. ctx, cancel := context.WithTimeout(req.Context(), 5*time.Second)
  284. defer cancel()
  285. return rejectPrivateHost(ctx, req.URL.Hostname())
  286. }
  287. req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, sub.Url, nil)
  288. if err != nil {
  289. s.recordError(sub, err)
  290. return nil, err
  291. }
  292. req.Header.Set("User-Agent", "3x-ui-outbound-sub/1.0")
  293. resp, err := client.Do(req)
  294. if err != nil {
  295. s.recordError(sub, err)
  296. return nil, err
  297. }
  298. defer resp.Body.Close()
  299. if resp.StatusCode != http.StatusOK {
  300. err := fmt.Errorf("http %d", resp.StatusCode)
  301. s.recordError(sub, err)
  302. return nil, err
  303. }
  304. body, err := readBoundedOutboundSubscriptionBody(resp.Body)
  305. if err != nil {
  306. s.recordError(sub, err)
  307. return nil, err
  308. }
  309. parsed, identities, err := link.ParseSubscriptionBody(body)
  310. if err != nil {
  311. s.recordError(sub, err)
  312. return nil, err
  313. }
  314. // Load previous identities -> tags for stability
  315. prev := map[string]string{}
  316. if strings.TrimSpace(sub.LinkIdentities) != "" {
  317. _ = json.Unmarshal([]byte(sub.LinkIdentities), &prev)
  318. }
  319. // Also load previous outbounds so we can reuse tags even for identities we
  320. // temporarily lost (defensive).
  321. prevTagByIndex := map[int]string{}
  322. if strings.TrimSpace(sub.LastFetchedOutbounds) != "" {
  323. var prevObs []any
  324. if json.Unmarshal([]byte(sub.LastFetchedOutbounds), &prevObs) == nil {
  325. for i, o := range prevObs {
  326. if m, ok := o.(map[string]any); ok {
  327. if tag, _ := m["tag"].(string); tag != "" {
  328. prevTagByIndex[i] = tag
  329. }
  330. }
  331. }
  332. }
  333. }
  334. // Assign tags with stability (identity reuse, positional fallback, then a
  335. // fresh allocation), keeping tags unique within this batch. Extracted into a
  336. // pure function so it can be unit-tested without network/DB. Tags are written
  337. // back into the parsed outbounds in place.
  338. assigned := assignStableTags(parsed, identities, prev, prevTagByIndex, sub.Id, sub.TagPrefix)
  339. // Persist identities for next time
  340. newIdent := map[string]string{}
  341. for i, id := range identities {
  342. newIdent[id] = assigned[i]
  343. }
  344. identJSON, _ := json.Marshal(newIdent)
  345. asAny := make([]any, len(parsed))
  346. for i := range parsed {
  347. asAny[i] = map[string]any(parsed[i])
  348. }
  349. kept, droppedByCore := filterOutboundsRejectedByCore(fmt.Sprintf("outbound sub %d", sub.Id), asAny)
  350. // Persist the outbounds (as compact JSON array)
  351. obsJSON, _ := json.Marshal(kept)
  352. sub.LastFetchedOutbounds = string(obsJSON)
  353. sub.LinkIdentities = string(identJSON)
  354. sub.LastUpdated = time.Now().Unix()
  355. sub.LastError = ""
  356. if len(droppedByCore) > 0 {
  357. sub.LastError = fmt.Sprintf("dropped %d outbound(s) the xray core rejects: %s", len(droppedByCore), droppedByCore[0])
  358. }
  359. if err := database.GetDB().Save(sub).Error; err != nil {
  360. return nil, err
  361. }
  362. return kept, nil
  363. }
  364. func (s *OutboundSubscriptionService) recordError(sub *model.OutboundSubscription, err error) {
  365. sub.LastError = err.Error()
  366. _ = database.GetDB().Model(sub).Update("last_error", sub.LastError).Error
  367. }
  368. // assignStableTags assigns a tag to each parsed outbound, preferring stability:
  369. // 1. reuse the tag previously mapped to the link's identity (prev),
  370. // 2. else reuse the tag at the same position from the last fetch (prevTagByIndex),
  371. // 3. else allocate a fresh tag from the prefix + remark (link.SuggestTag).
  372. //
  373. // Tags are kept unique within the batch by appending "-N" on collision, and are
  374. // written back into parsed[i]["tag"]. The returned slice holds the assigned tags
  375. // in order. When tagPrefix is empty a "sub<subID>-" prefix is used for fresh tags.
  376. func assignStableTags(parsed []link.Outbound, identities []string, prev map[string]string, prevTagByIndex map[int]string, subID int, tagPrefix string) []string {
  377. used := map[string]bool{} // uniqueness within this refresh batch
  378. assigned := make([]string, len(parsed))
  379. for i := range parsed {
  380. id := ""
  381. if i < len(identities) {
  382. id = identities[i]
  383. }
  384. candidate := ""
  385. if old, ok := prev[id]; ok && old != "" {
  386. candidate = old
  387. }
  388. if candidate == "" {
  389. // try to reuse by rough positional match from previous fetch (best effort)
  390. if old, ok := prevTagByIndex[i]; ok && old != "" {
  391. candidate = old
  392. }
  393. }
  394. if candidate == "" {
  395. // fresh allocation
  396. prefix := tagPrefix
  397. if prefix == "" {
  398. prefix = fmt.Sprintf("sub%d-", subID)
  399. }
  400. remark := ""
  401. if m, ok := parsed[i]["tag"].(string); ok {
  402. remark = m
  403. }
  404. candidate = link.SuggestTag(prefix, remark, i)
  405. }
  406. // ensure local uniqueness inside this batch
  407. final := candidate
  408. for k := 1; used[final]; k++ {
  409. final = fmt.Sprintf("%s-%d", candidate, k)
  410. }
  411. used[final] = true
  412. assigned[i] = final
  413. // write back the tag into the outbound
  414. parsed[i]["tag"] = final
  415. }
  416. return assigned
  417. }
  418. // AllActiveOutbounds returns the concatenation of the last-fetched outbounds
  419. // for every enabled subscription. This is the set that should be merged into
  420. // the final Xray config. Order: subscription creation order (by id asc) so
  421. // that later subscriptions can shadow earlier ones if the admin uses colliding
  422. // prefixes (last writer wins inside xray, but we try to keep tags unique).
  423. func (s *OutboundSubscriptionService) AllActiveOutbounds() ([]any, error) {
  424. prepend, appendList, err := s.activeOutboundsSplit()
  425. if err != nil {
  426. return nil, err
  427. }
  428. return append(prepend, appendList...), nil
  429. }
  430. // activeOutboundsSplit returns the active subscription outbounds split into those
  431. // that should be placed BEFORE the manual template outbounds (Prepend) and those
  432. // placed AFTER. Within each group, subscriptions are ordered by Priority (then id)
  433. // so the admin can control the merged order.
  434. func (s *OutboundSubscriptionService) activeOutboundsSplit() (prepend []any, appendList []any, err error) {
  435. db := database.GetDB()
  436. var subs []*model.OutboundSubscription
  437. if err := db.Where("enabled = ?", true).Order("priority asc, id asc").Find(&subs).Error; err != nil {
  438. return nil, nil, err
  439. }
  440. for _, sub := range subs {
  441. if strings.TrimSpace(sub.LastFetchedOutbounds) == "" {
  442. continue
  443. }
  444. var arr []any
  445. if err := json.Unmarshal([]byte(sub.LastFetchedOutbounds), &arr); err != nil {
  446. logger.Warningf("outbound sub %d has corrupt LastFetchedOutbounds: %v", sub.Id, err)
  447. continue
  448. }
  449. arr, _ = filterOutboundsRejectedByCore(fmt.Sprintf("outbound sub %d", sub.Id), arr)
  450. if sub.Prepend {
  451. prepend = append(prepend, arr...)
  452. } else {
  453. appendList = append(appendList, arr...)
  454. }
  455. }
  456. return prepend, appendList, nil
  457. }
  458. // Move shifts a subscription one step up or down in the priority order and
  459. // re-normalizes all priorities to a 0..n-1 sequence.
  460. func (s *OutboundSubscriptionService) Move(id int, up bool) error {
  461. db := database.GetDB()
  462. var subs []*model.OutboundSubscription
  463. if err := db.Order("priority asc, id asc").Find(&subs).Error; err != nil {
  464. return err
  465. }
  466. idx := -1
  467. for i, sub := range subs {
  468. if sub.Id == id {
  469. idx = i
  470. break
  471. }
  472. }
  473. if idx == -1 {
  474. return common.NewError("subscription not found")
  475. }
  476. swap := idx + 1
  477. if up {
  478. swap = idx - 1
  479. }
  480. if swap < 0 || swap >= len(subs) {
  481. return nil // already at the edge
  482. }
  483. subs[idx], subs[swap] = subs[swap], subs[idx]
  484. for i, sub := range subs {
  485. if sub.Priority != i {
  486. if err := db.Model(sub).Update("priority", i).Error; err != nil {
  487. return err
  488. }
  489. }
  490. }
  491. return nil
  492. }
  493. // AllActiveOutboundTags returns only the tags of active subscription outbounds.
  494. // Useful for populating balancer / routing selectors without shipping full objects.
  495. func (s *OutboundSubscriptionService) AllActiveOutboundTags() ([]string, error) {
  496. obs, err := s.AllActiveOutbounds()
  497. if err != nil {
  498. return nil, err
  499. }
  500. tags := make([]string, 0, len(obs))
  501. for _, o := range obs {
  502. if m, ok := o.(map[string]any); ok {
  503. if t, _ := m["tag"].(string); t != "" {
  504. tags = append(tags, t)
  505. }
  506. }
  507. }
  508. return tags, nil
  509. }
  510. /*
  511. Tag stability strategy (important for balancers and routing rules)
  512. When a subscription is refreshed we try very hard to keep the *same* tag for the
  513. same logical outbound so that existing balancers and routing rules keep working.
  514. How we do it:
  515. - On every successful parse we compute a stable "identity" for each link
  516. (the core of the URI with the remark fragment removed, or for vmess the inner
  517. JSON without the "ps" field).
  518. - We persist a map identity -> tag in the LinkIdentities column.
  519. - On the next refresh, if we see the same identity again we reuse the previous tag,
  520. even if the remark changed or minor parameters moved.
  521. - Only when we have never seen the identity before do we allocate a fresh tag
  522. using the user-supplied TagPrefix + slug(remark) (or an index fallback).
  523. - Within one refresh we still deduplicate with -N suffixes.
  524. Consequences for balancers / routing:
  525. - If you use an *exact* tag in a balancer selector or a routing rule, that
  526. specific server will continue to be used after refreshes (as long as the
  527. provider still returns a link that produces the same identity).
  528. - If you use a *prefix/wildcard* selector (e.g. "hk-*", "sg-.*"), then any
  529. *new* servers that the subscription later returns will automatically be
  530. eligible for that balancer on the next Xray reload — this is the recommended
  531. way to "subscribe to a pool".
  532. - When a server disappears from the subscription, its tag simply stops
  533. existing in the final outbounds array. The balancer will have fewer
  534. candidates. If you configured a `fallbackTag` on the balancer, Xray will use
  535. it. Otherwise connections that would have used the missing member may fail
  536. or be routed by the next rule.
  537. - If the provider rotates credentials/UUIDs/hosts for a server, the identity
  538. changes → we treat it as a brand new outbound and give it a new tag. Any
  539. balancer/rule that referenced the *old* tag will no longer see it. This is
  540. an inherent limitation of subscription-based outbounds.
  541. We deliberately do *not* mutate the saved xrayTemplateConfig. Subscription
  542. outbounds are always injected at runtime in GetXrayConfig.
  543. */