outbound_subscription.go 22 KB

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