xray.go 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245
  1. package service
  2. import (
  3. "encoding/json"
  4. "errors"
  5. "fmt"
  6. "path"
  7. "path/filepath"
  8. "runtime"
  9. "strings"
  10. "sync"
  11. "github.com/mhsanaei/3x-ui/v3/internal/config"
  12. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  13. "github.com/mhsanaei/3x-ui/v3/internal/logger"
  14. "github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
  15. "github.com/mhsanaei/3x-ui/v3/internal/xray"
  16. "go.uber.org/atomic"
  17. )
  18. var (
  19. p *xray.Process
  20. lock sync.Mutex
  21. isNeedXrayRestart atomic.Bool // Indicates that restart was requested for Xray
  22. isManuallyStopped atomic.Bool // Indicates that Xray was stopped manually from the panel
  23. result string
  24. )
  25. // XrayService provides business logic for Xray process management.
  26. // It handles starting, stopping, restarting Xray, and managing its configuration.
  27. type XrayService struct {
  28. inboundService InboundService
  29. settingService SettingService
  30. nodeService NodeService
  31. xrayAPI xray.XrayAPI
  32. }
  33. // IsXrayRunning checks if the Xray process is currently running.
  34. func (s *XrayService) IsXrayRunning() bool {
  35. return p != nil && p.IsRunning()
  36. }
  37. // XrayProcess returns the current Xray process instance (may be nil when Xray
  38. // is not running). It exposes the package-level process to callers outside this
  39. // package (e.g. the tgbot subpackage) without changing access semantics.
  40. func XrayProcess() *xray.Process {
  41. return p
  42. }
  43. // GetXrayErr returns the error from the Xray process, if any.
  44. func (s *XrayService) GetXrayErr() error {
  45. if p == nil {
  46. return nil
  47. }
  48. err := p.GetErr()
  49. if err == nil {
  50. return nil
  51. }
  52. if runtime.GOOS == "windows" && err.Error() == "exit status 1" {
  53. // exit status 1 on Windows means that Xray process was killed
  54. // as we kill process to stop in on Windows, this is not an error
  55. return nil
  56. }
  57. return err
  58. }
  59. // GetXrayResult returns the result string from the Xray process.
  60. func (s *XrayService) GetXrayResult() string {
  61. if result != "" {
  62. return result
  63. }
  64. if s.IsXrayRunning() {
  65. return ""
  66. }
  67. if p == nil {
  68. return ""
  69. }
  70. result = p.GetResult()
  71. if runtime.GOOS == "windows" && result == "exit status 1" {
  72. // exit status 1 on Windows means that Xray process was killed
  73. // as we kill process to stop in on Windows, this is not an error
  74. return ""
  75. }
  76. return result
  77. }
  78. // GetXrayVersion returns the version of the running Xray process.
  79. func (s *XrayService) GetXrayVersion() string {
  80. if p == nil {
  81. return "Unknown"
  82. }
  83. return p.GetXrayVersion()
  84. }
  85. // RemoveIndex removes an element at the specified index from a slice.
  86. // Returns a new slice with the element removed.
  87. func RemoveIndex(s []any, index int) []any {
  88. return append(s[:index], s[index+1:]...)
  89. }
  90. // GetXrayConfig retrieves and builds the Xray configuration from settings and inbounds.
  91. func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
  92. templateConfig, err := s.settingService.GetXrayConfigTemplate()
  93. if err != nil {
  94. return nil, err
  95. }
  96. xrayConfig := &xray.Config{}
  97. err = json.Unmarshal([]byte(templateConfig), xrayConfig)
  98. if err != nil {
  99. return nil, err
  100. }
  101. xrayConfig.LogConfig = resolveXrayLogPaths(xrayConfig.LogConfig)
  102. xrayConfig.API = ensureAPIServices(xrayConfig.API)
  103. xrayConfig.Policy = ensureStatsPolicy(xrayConfig.Policy)
  104. xrayConfig.RouterConfig = stripDisabledRules(xrayConfig.RouterConfig)
  105. // Template outbounds authored before the xray-core #6258 XHTTP rename may
  106. // still carry sessionPlacement/sessionKey; lift them too (same reason as
  107. // the per-inbound lift below).
  108. xrayConfig.OutboundConfigs = liftOutboundsXhttpSessionIDKeys(xrayConfig.OutboundConfigs)
  109. _, _, _ = s.inboundService.AddTraffic(nil, nil)
  110. inbounds, err := s.inboundService.GetAllInbounds()
  111. if err != nil {
  112. return nil, err
  113. }
  114. for _, inbound := range inbounds {
  115. if !inbound.Enable {
  116. continue
  117. }
  118. if inbound.NodeID != nil {
  119. continue
  120. }
  121. if inbound.Protocol == model.MTProto {
  122. continue
  123. }
  124. settings := map[string]any{}
  125. _ = json.Unmarshal([]byte(inbound.Settings), &settings)
  126. dbClients, listErr := s.inboundService.clientService.ListForInbound(nil, inbound.Id)
  127. if listErr != nil {
  128. return nil, listErr
  129. }
  130. clientStats := inbound.ClientStats
  131. enableMap := make(map[string]bool, len(clientStats))
  132. for _, clientTraffic := range clientStats {
  133. enableMap[clientTraffic.Email] = clientTraffic.Enable
  134. }
  135. var finalClients []any
  136. var wgPeers []any
  137. for i := range dbClients {
  138. c := dbClients[i]
  139. if enable, exists := enableMap[c.Email]; exists && !enable {
  140. logger.Infof("Remove Inbound User %s due to expiration or traffic limit", c.Email)
  141. continue
  142. }
  143. if !c.Enable {
  144. continue
  145. }
  146. flow := c.Flow
  147. if flow == "xtls-rprx-vision-udp443" {
  148. flow = "xtls-rprx-vision"
  149. }
  150. entry := map[string]any{"email": c.Email}
  151. switch inbound.Protocol {
  152. case model.VLESS:
  153. if c.ID != "" {
  154. entry["id"] = c.ID
  155. }
  156. if flow != "" {
  157. entry["flow"] = flow
  158. }
  159. if c.Reverse != nil {
  160. entry["reverse"] = c.Reverse
  161. }
  162. case model.VMESS:
  163. if c.ID != "" {
  164. entry["id"] = c.ID
  165. }
  166. if c.Security != "" {
  167. entry["security"] = c.Security
  168. }
  169. case model.Trojan:
  170. if c.Password != "" {
  171. entry["password"] = c.Password
  172. }
  173. if flow != "" {
  174. entry["flow"] = flow
  175. }
  176. case model.Shadowsocks:
  177. if c.Password != "" {
  178. entry["password"] = c.Password
  179. }
  180. case model.Hysteria:
  181. if c.Auth != "" {
  182. entry["auth"] = c.Auth
  183. }
  184. case model.WireGuard:
  185. wgPeers = append(wgPeers, model.WireguardPeerFromClient(c))
  186. continue
  187. }
  188. finalClients = append(finalClients, entry)
  189. }
  190. var mutated bool
  191. if inbound.Protocol == model.WireGuard {
  192. delete(settings, "clients")
  193. if wgPeers == nil {
  194. wgPeers = []any{}
  195. }
  196. settings["peers"] = wgPeers
  197. mutated = true
  198. } else {
  199. _, hadClients := settings["clients"]
  200. mutated = hadClients || len(finalClients) > 0
  201. if mutated {
  202. settings["clients"] = finalClients
  203. }
  204. }
  205. if inboundCanHostFallbacks(inbound) {
  206. fallbacks, fbErr := s.inboundService.fallbackService.BuildFallbacksJSON(nil, inbound.Id)
  207. if fbErr != nil {
  208. return nil, fbErr
  209. }
  210. if len(fallbacks) > 0 {
  211. generic := make([]any, 0, len(fallbacks))
  212. for _, f := range fallbacks {
  213. generic = append(generic, f)
  214. }
  215. settings["fallbacks"] = generic
  216. mutated = true
  217. }
  218. }
  219. if mutated {
  220. modifiedSettings, err := json.MarshalIndent(settings, "", " ")
  221. if err != nil {
  222. return nil, err
  223. }
  224. inbound.Settings = string(modifiedSettings)
  225. }
  226. if len(inbound.StreamSettings) > 0 {
  227. // Unmarshal stream JSON
  228. var stream map[string]any
  229. _ = json.Unmarshal([]byte(inbound.StreamSettings), &stream)
  230. // Remove the "settings" field under "tlsSettings" and "realitySettings"
  231. tlsSettings, ok1 := stream["tlsSettings"].(map[string]any)
  232. realitySettings, ok2 := stream["realitySettings"].(map[string]any)
  233. if ok1 || ok2 {
  234. if ok1 {
  235. delete(tlsSettings, "settings")
  236. } else if ok2 {
  237. delete(realitySettings, "settings")
  238. }
  239. }
  240. delete(stream, "externalProxy")
  241. // finalmask.tcp + REALITY panics Xray-core on the first connection
  242. // (XTLS/Xray-core#6453). AddInbound/UpdateInbound reject this
  243. // combination at save time, but a row saved before that guard
  244. // existed (upgrade, node sync, restored backup, direct DB edit)
  245. // would still crash Xray on the next restart without this — drop
  246. // it here too, the same way liftXhttpSessionIDKeys and
  247. // HealShadowsocksClientMethods heal other legacy data in place.
  248. if len(finalMaskRealityTcpMasks(stream)) > 0 {
  249. logger.Warningf("Inbound %q: dropping finalmask, incompatible with REALITY security (crashes Xray-core, see XTLS/Xray-core#6453)", inbound.Tag)
  250. delete(stream, "finalmask")
  251. }
  252. // xray-core v26.6.22 (#6258) renamed the XHTTP session keys and
  253. // kept no fallback. Lift legacy sessionPlacement/sessionKey onto the
  254. // new names here so inbounds stored before the rename keep working
  255. // without the admin re-saving them.
  256. liftXhttpSessionIDKeys(stream)
  257. newStream, err := json.MarshalIndent(stream, "", " ")
  258. if err != nil {
  259. return nil, err
  260. }
  261. inbound.StreamSettings = string(newStream)
  262. }
  263. if inbound.Protocol == model.Shadowsocks {
  264. if healed, ok := model.HealShadowsocksClientMethods(inbound.Settings); ok {
  265. inbound.Settings = healed
  266. }
  267. }
  268. inboundConfig := inbound.GenXrayInboundConfig()
  269. xrayConfig.InboundConfigs = append(xrayConfig.InboundConfigs, *inboundConfig)
  270. }
  271. // Merge subscription-derived outbounds (if any) into the final outbounds array.
  272. // These are additive: each subscription is placed before or after the template
  273. // outbounds based on its Prepend flag, ordered by Priority. Tags assigned by the
  274. // subscription service are kept stable across refreshes so that balancers and
  275. // routing rules continue to work.
  276. subSvc := &OutboundSubscriptionService{}
  277. if prepend, appendList, err := subSvc.activeOutboundsSplit(); err == nil && (len(prepend) > 0 || len(appendList) > 0) {
  278. mergeSubscriptionOutbounds(xrayConfig, prepend, appendList)
  279. }
  280. // Route opted-in local mtproto inbounds through the core's router. Each one
  281. // gets a loopback SOCKS bridge — tagged with the inbound's own tag so it is
  282. // matchable in routing rules — that its mtg sidecar dials Telegram through.
  283. // Done after the subscription merge so a selected subscription outbound (or
  284. // balancer) is a valid rule target.
  285. for i := range inbounds {
  286. inbound := inbounds[i]
  287. if inbound.Protocol != model.MTProto || !inbound.Enable || inbound.NodeID != nil {
  288. continue
  289. }
  290. injectMtprotoEgress(xrayConfig, inbound)
  291. }
  292. // Wire the panel's own HTTP traffic through the configured outbound, after
  293. // the subscription merge so subscription outbound tags are valid targets.
  294. if egressTag, err := s.settingService.GetPanelOutbound(); err != nil {
  295. logger.Warning("read panelOutbound setting failed:", err)
  296. } else if egressTag != "" {
  297. injectPanelEgress(xrayConfig, egressTag)
  298. }
  299. nodes, err := s.nodeService.GetAll()
  300. if err != nil {
  301. logger.Warning("read nodes for egress injection failed:", err)
  302. } else {
  303. injectNodeEgresses(xrayConfig, nodes)
  304. }
  305. return xrayConfig, nil
  306. }
  307. // PanelEgressInboundTag is the tag of the loopback SOCKS inbound injected into
  308. // the generated config when a panel outbound is configured. The panel's own
  309. // HTTP clients dial through it to egress via the chosen outbound.
  310. const PanelEgressInboundTag = "panel-egress"
  311. // panelEgressBasePort is the first port tried for the egress bridge; ports
  312. // already taken by other inbounds in the generated config are skipped.
  313. const panelEgressBasePort = 62790
  314. // injectPanelEgress appends a loopback SOCKS inbound to the generated config
  315. // and prepends a routing rule sending it to outboundTag. Both live only in the
  316. // generated config — the stored template is never modified — and both are
  317. // hot-appliable, so changing the panel outbound never restarts the core.
  318. func injectPanelEgress(cfg *xray.Config, outboundTag string) {
  319. for i := range cfg.InboundConfigs {
  320. if cfg.InboundConfigs[i].Tag == PanelEgressInboundTag {
  321. logger.Warning("panel egress: inbound tag [", PanelEgressInboundTag, "] already exists, skipping injection")
  322. return
  323. }
  324. }
  325. // The rule must exist before the inbound takes traffic, otherwise the
  326. // bridge would silently egress through the default outbound instead.
  327. routing := map[string]any{}
  328. if len(cfg.RouterConfig) > 0 {
  329. if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
  330. logger.Warning("panel egress: routing section is unparsable, skipping injection:", err)
  331. return
  332. }
  333. }
  334. rules, _ := routing["rules"].([]any)
  335. rule := map[string]any{
  336. "type": "field",
  337. "inboundTag": []any{PanelEgressInboundTag},
  338. }
  339. // The configured tag may name a routing balancer instead of a concrete
  340. // outbound. A field rule can target either, so emit the matching key —
  341. // balancerTag load-balances the panel's own traffic across the balancer's
  342. // outbounds, while a plain outbound tag keeps the original behavior.
  343. if routingTagIsBalancer(routing, outboundTag) {
  344. rule["balancerTag"] = outboundTag
  345. } else {
  346. rule["outboundTag"] = outboundTag
  347. }
  348. routing["rules"] = append([]any{rule}, rules...)
  349. newRouting, err := json.Marshal(routing)
  350. if err != nil {
  351. logger.Warning("panel egress: failed to rebuild routing section, skipping injection:", err)
  352. return
  353. }
  354. cfg.RouterConfig = json_util.RawMessage(newRouting)
  355. used := make(map[int]struct{}, len(cfg.InboundConfigs))
  356. for i := range cfg.InboundConfigs {
  357. used[cfg.InboundConfigs[i].Port] = struct{}{}
  358. }
  359. port := panelEgressBasePort
  360. for {
  361. if _, taken := used[port]; !taken {
  362. break
  363. }
  364. port++
  365. }
  366. cfg.InboundConfigs = append(cfg.InboundConfigs, xray.InboundConfig{
  367. Listen: json_util.RawMessage(`"127.0.0.1"`),
  368. Port: port,
  369. Protocol: "socks",
  370. Settings: json_util.RawMessage(`{"auth":"noauth","udp":false}`),
  371. Tag: PanelEgressInboundTag,
  372. })
  373. }
  374. // NodeEgressInboundTag returns the loopback SOCKS inbound tag for a given node.
  375. func NodeEgressInboundTag(nodeID int) string {
  376. return fmt.Sprintf("node-egress-%d", nodeID)
  377. }
  378. // nodeEgressBasePort is the first port tried for node egress bridges.
  379. const nodeEgressBasePort = 62800
  380. // injectNodeEgresses appends a loopback SOCKS inbound per enabled node that has
  381. // an OutboundTag, and prepends a routing rule sending that inbound's traffic to
  382. // the selected outbound tag. These bridges are hot-appliable.
  383. func injectNodeEgresses(cfg *xray.Config, nodes []*model.Node) {
  384. routing := map[string]any{}
  385. if len(cfg.RouterConfig) > 0 {
  386. if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
  387. logger.Warning("node egress: routing section is unparsable, skipping injection:", err)
  388. return
  389. }
  390. }
  391. used := make(map[int]struct{}, len(cfg.InboundConfigs))
  392. usedTags := make(map[string]struct{}, len(cfg.InboundConfigs))
  393. for i := range cfg.InboundConfigs {
  394. used[cfg.InboundConfigs[i].Port] = struct{}{}
  395. usedTags[cfg.InboundConfigs[i].Tag] = struct{}{}
  396. }
  397. rules, _ := routing["rules"].([]any)
  398. newRules := make([]any, 0)
  399. for _, n := range nodes {
  400. if !n.Enable || n.OutboundTag == "" {
  401. continue
  402. }
  403. tag := NodeEgressInboundTag(n.Id)
  404. if _, exists := usedTags[tag]; exists {
  405. logger.Warning("node egress: inbound tag [", tag, "] already exists, skipping")
  406. continue
  407. }
  408. usedTags[tag] = struct{}{}
  409. rule := map[string]any{
  410. "type": "field",
  411. "inboundTag": []any{tag},
  412. }
  413. if routingTagIsBalancer(routing, n.OutboundTag) {
  414. rule["balancerTag"] = n.OutboundTag
  415. } else {
  416. rule["outboundTag"] = n.OutboundTag
  417. }
  418. newRules = append(newRules, rule)
  419. port := nodeEgressBasePort + n.Id
  420. for {
  421. if _, taken := used[port]; !taken {
  422. break
  423. }
  424. port++
  425. }
  426. used[port] = struct{}{}
  427. cfg.InboundConfigs = append(cfg.InboundConfigs, xray.InboundConfig{
  428. Listen: json_util.RawMessage(`"127.0.0.1"`),
  429. Port: port,
  430. Protocol: "socks",
  431. Settings: json_util.RawMessage(`{"auth":"noauth","udp":false}`),
  432. Tag: tag,
  433. })
  434. }
  435. if len(newRules) == 0 {
  436. return
  437. }
  438. routing["rules"] = append(newRules, rules...)
  439. newRouting, err := json.Marshal(routing)
  440. if err != nil {
  441. logger.Warning("node egress: failed to rebuild routing section, skipping injection:", err)
  442. return
  443. }
  444. cfg.RouterConfig = json_util.RawMessage(newRouting)
  445. }
  446. // routingTagIsBalancer reports whether tag names a balancer in the parsed
  447. // routing section. The panel-egress rule targets a balancer via balancerTag and
  448. // a concrete outbound via outboundTag, so the caller picks the key from this.
  449. func routingTagIsBalancer(routing map[string]any, tag string) bool {
  450. if tag == "" {
  451. return false
  452. }
  453. balancers, ok := routing["balancers"].([]any)
  454. if !ok {
  455. return false
  456. }
  457. for _, b := range balancers {
  458. bm, ok := b.(map[string]any)
  459. if !ok {
  460. continue
  461. }
  462. if t, ok := bm["tag"].(string); ok && t == tag {
  463. return true
  464. }
  465. }
  466. return false
  467. }
  468. // mtprotoEgressSocksSettings is the loopback SOCKS server a routed mtproto
  469. // inbound exposes for its mtg sidecar to dial Telegram through. mtg makes plain
  470. // TCP connections, so UDP is left off (matching the panel egress bridge).
  471. const mtprotoEgressSocksSettings = `{"auth":"noauth","udp":false}`
  472. // injectMtprotoEgress wires one routed mtproto inbound into the generated
  473. // config: it appends a loopback SOCKS inbound (tagged with the inbound's own tag,
  474. // on the egress port persisted in settings) and, when an outbound is selected,
  475. // prepends a routing rule sending that tag to it. Both live only in the generated
  476. // config — the stored template is untouched — and both are hot-appliable, so
  477. // toggling routing never forces a full Xray restart. Mirrors injectPanelEgress.
  478. func injectMtprotoEgress(cfg *xray.Config, inbound *model.Inbound) {
  479. var parsed struct {
  480. RouteThroughXray bool `json:"routeThroughXray"`
  481. RouteXrayPort int `json:"routeXrayPort"`
  482. OutboundTag string `json:"outboundTag"`
  483. }
  484. if err := json.Unmarshal([]byte(inbound.Settings), &parsed); err != nil {
  485. return
  486. }
  487. if !parsed.RouteThroughXray || parsed.RouteXrayPort <= 0 || inbound.Tag == "" {
  488. return
  489. }
  490. tag := inbound.Tag
  491. for i := range cfg.InboundConfigs {
  492. if cfg.InboundConfigs[i].Tag == tag {
  493. logger.Warning("mtproto egress: inbound tag [", tag, "] already present in generated config, skipping bridge")
  494. return
  495. }
  496. }
  497. if parsed.OutboundTag != "" {
  498. routing := map[string]any{}
  499. parseOK := true
  500. if len(cfg.RouterConfig) > 0 {
  501. if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
  502. logger.Warning("mtproto egress: routing section is unparsable, skipping rule:", err)
  503. parseOK = false
  504. }
  505. }
  506. if parseOK {
  507. rules, _ := routing["rules"].([]any)
  508. rule := map[string]any{
  509. "type": "field",
  510. "inboundTag": []any{tag},
  511. }
  512. if routingTagIsBalancer(routing, parsed.OutboundTag) {
  513. rule["balancerTag"] = parsed.OutboundTag
  514. } else {
  515. rule["outboundTag"] = parsed.OutboundTag
  516. }
  517. routing["rules"] = append([]any{rule}, rules...)
  518. if newRouting, err := json.Marshal(routing); err == nil {
  519. cfg.RouterConfig = json_util.RawMessage(newRouting)
  520. } else {
  521. logger.Warning("mtproto egress: failed to rebuild routing section, skipping rule:", err)
  522. }
  523. }
  524. }
  525. cfg.InboundConfigs = append(cfg.InboundConfigs, xray.InboundConfig{
  526. Listen: json_util.RawMessage(`"127.0.0.1"`),
  527. Port: parsed.RouteXrayPort,
  528. Protocol: "socks",
  529. Settings: json_util.RawMessage(mtprotoEgressSocksSettings),
  530. Tag: tag,
  531. })
  532. }
  533. // mergeSubscriptionOutbounds appends the subscription outbounds to the
  534. // OutboundConfigs array of the xray config. It works on the already-unmarshaled
  535. // template so that manually configured outbounds are never overwritten.
  536. //
  537. // Safety: if we cannot parse the template's outbounds array, we leave
  538. // OutboundConfigs exactly as it came from the template (we do not inject
  539. // subscription outbounds). This prevents us from accidentally dropping the
  540. // user's manually configured outbounds when the template is in a weird state.
  541. func mergeSubscriptionOutbounds(cfg *xray.Config, prepend, appendList []any) {
  542. if len(prepend) == 0 && len(appendList) == 0 {
  543. return
  544. }
  545. var templateOutbounds []any
  546. if len(cfg.OutboundConfigs) > 0 {
  547. if err := json.Unmarshal(cfg.OutboundConfigs, &templateOutbounds); err != nil {
  548. // Corrupt template outbounds — do not touch the field at all.
  549. // The user will see problems on Xray start / next save.
  550. return
  551. }
  552. }
  553. var merged []any
  554. merged = append(merged, prepend...)
  555. merged = append(merged, templateOutbounds...)
  556. merged = append(merged, appendList...)
  557. combined, err := json.MarshalIndent(merged, "", " ")
  558. if err != nil {
  559. return
  560. }
  561. cfg.OutboundConfigs = json_util.RawMessage(combined)
  562. }
  563. // ensureAPIServices guarantees the gRPC services the panel depends on are
  564. // listed in the generated config's api block: HandlerService and StatsService
  565. // have always been required for inbound/user management and traffic polling,
  566. // and RoutingService enables hot routing reload on templates saved before it
  567. // was added to the default template. The stored template itself is not
  568. // modified — only the generated runtime config.
  569. func ensureAPIServices(api json_util.RawMessage) json_util.RawMessage {
  570. if len(api) == 0 {
  571. // No api block means the panel's API integration is deliberately
  572. // disabled; don't resurrect it behind the user's back.
  573. return api
  574. }
  575. var parsed map[string]any
  576. if err := json.Unmarshal(api, &parsed); err != nil {
  577. return api
  578. }
  579. services, _ := parsed["services"].([]any)
  580. have := make(map[string]bool, len(services))
  581. for _, svc := range services {
  582. if name, ok := svc.(string); ok {
  583. have[name] = true
  584. }
  585. }
  586. added := false
  587. for _, name := range []string{"HandlerService", "StatsService", "RoutingService"} {
  588. if !have[name] {
  589. services = append(services, name)
  590. added = true
  591. }
  592. }
  593. if !added {
  594. return api
  595. }
  596. parsed["services"] = services
  597. out, err := json.Marshal(parsed)
  598. if err != nil {
  599. return api
  600. }
  601. return out
  602. }
  603. // ensureStatsPolicy guarantees every policy level in the generated config has
  604. // statsUserOnline enabled, so the core tracks per-email online IPs for the
  605. // panel's online view and access-log-free IP limiting. Generated clients carry
  606. // no explicit level, so level "0" is created when absent. The flag is panel
  607. // infrastructure and is forced on even over an explicit false in the template,
  608. // same as the api services above. An entirely missing or unparsable policy
  609. // block is left alone; the stored template itself is never modified — only the
  610. // generated runtime config.
  611. func ensureStatsPolicy(policy json_util.RawMessage) json_util.RawMessage {
  612. if len(policy) == 0 {
  613. return policy
  614. }
  615. var parsed map[string]any
  616. if err := json.Unmarshal(policy, &parsed); err != nil {
  617. return policy
  618. }
  619. levels, _ := parsed["levels"].(map[string]any)
  620. if levels == nil {
  621. levels = make(map[string]any)
  622. }
  623. if _, ok := levels["0"]; !ok {
  624. levels["0"] = map[string]any{}
  625. }
  626. changed := false
  627. for _, raw := range levels {
  628. level, ok := raw.(map[string]any)
  629. if !ok {
  630. continue
  631. }
  632. if enabled, ok := level["statsUserOnline"].(bool); !ok || !enabled {
  633. level["statsUserOnline"] = true
  634. changed = true
  635. }
  636. }
  637. if !changed {
  638. return policy
  639. }
  640. parsed["levels"] = levels
  641. out, err := json.Marshal(parsed)
  642. if err != nil {
  643. return policy
  644. }
  645. return out
  646. }
  647. func resolveXrayLogPaths(logCfg json_util.RawMessage) json_util.RawMessage {
  648. if len(logCfg) == 0 {
  649. return logCfg
  650. }
  651. var parsed map[string]any
  652. if err := json.Unmarshal(logCfg, &parsed); err != nil {
  653. return logCfg
  654. }
  655. changed := false
  656. for _, key := range []string{"access", "error"} {
  657. v, ok := parsed[key].(string)
  658. if !ok {
  659. continue
  660. }
  661. trimmed := strings.TrimSpace(v)
  662. if trimmed == "" || strings.EqualFold(trimmed, "none") {
  663. continue
  664. }
  665. base := path.Base(filepath.ToSlash(trimmed))
  666. if base == "" || base == "." || base == ".." || base == "/" {
  667. continue
  668. }
  669. confined := filepath.Join(config.GetLogFolder(), base)
  670. if confined == trimmed {
  671. continue
  672. }
  673. parsed[key] = confined
  674. changed = true
  675. }
  676. if !changed {
  677. return logCfg
  678. }
  679. out, err := json.Marshal(parsed)
  680. if err != nil {
  681. return logCfg
  682. }
  683. return out
  684. }
  685. // stripDisabledRules removes routing rules marked `enabled: false` from the
  686. // generated runtime config and strips the panel-only `enabled` key from the
  687. // rest, since xray-core has no such field. The internal api rule is always
  688. // kept (see isApiRule) so traffic stats can't be toggled off. The stored
  689. // template is untouched — only the generated config is filtered.
  690. func stripDisabledRules(routerCfg json_util.RawMessage) json_util.RawMessage {
  691. if len(routerCfg) == 0 {
  692. return routerCfg
  693. }
  694. var parsed map[string]any
  695. if err := json.Unmarshal(routerCfg, &parsed); err != nil {
  696. return routerCfg
  697. }
  698. rules, ok := parsed["rules"].([]any)
  699. if !ok || len(rules) == 0 {
  700. return routerCfg
  701. }
  702. var activeRules []any
  703. changed := false
  704. for _, rawRule := range rules {
  705. rule, ok := rawRule.(map[string]any)
  706. if !ok {
  707. activeRules = append(activeRules, rawRule)
  708. continue
  709. }
  710. if enabledRaw, exists := rule["enabled"]; exists {
  711. // The internal api rule carries traffic stats and must never be
  712. // dropped, even if it was somehow marked disabled.
  713. enabled, ok := enabledRaw.(bool)
  714. if ok && !enabled && !isApiRule(rule) {
  715. changed = true
  716. continue
  717. }
  718. delete(rule, "enabled")
  719. changed = true
  720. }
  721. activeRules = append(activeRules, rule)
  722. }
  723. if !changed {
  724. return routerCfg
  725. }
  726. parsed["rules"] = activeRules
  727. out, err := json.Marshal(parsed)
  728. if err != nil {
  729. return routerCfg
  730. }
  731. return out
  732. }
  733. // GetXrayTraffic fetches the current traffic statistics from the running Xray process.
  734. func (s *XrayService) GetXrayTraffic() ([]*xray.Traffic, []*xray.ClientTraffic, error) {
  735. if !s.IsXrayRunning() {
  736. err := errors.New("xray is not running")
  737. logger.Debug("Attempted to fetch Xray traffic, but Xray is not running:", err)
  738. return nil, nil, err
  739. }
  740. apiPort := p.GetAPIPort()
  741. if err := s.xrayAPI.Init(apiPort); err != nil {
  742. logger.Debug("Failed to initialize Xray API:", err)
  743. return nil, nil, err
  744. }
  745. defer s.xrayAPI.Close()
  746. traffic, clientTraffic, err := s.xrayAPI.GetTraffic()
  747. if err != nil {
  748. logger.Debug("Failed to fetch Xray traffic:", err)
  749. return nil, nil, err
  750. }
  751. return traffic, clientTraffic, nil
  752. }
  753. // GetOnlineUsers returns connection-based online users (email + source IPs)
  754. // from the running core's online-stats API. ok=false means the API is not
  755. // available — xray isn't running or the core predates the online-stats RPCs —
  756. // and callers must use the legacy traffic-delta / access-log paths. The
  757. // capability is probed lazily per process: an Unimplemented answer pins this
  758. // core as unsupported until the next restart, while transient errors leave the
  759. // capability undecided so a flaky poll can't lock in legacy mode.
  760. func (s *XrayService) GetOnlineUsers() ([]xray.OnlineUser, bool, error) {
  761. if !s.IsXrayRunning() {
  762. return nil, false, nil
  763. }
  764. if p.OnlineAPISupport() == xray.OnlineAPIUnsupported {
  765. return nil, false, nil
  766. }
  767. if err := s.xrayAPI.Init(p.GetAPIPort()); err != nil {
  768. logger.Debug("Failed to initialize Xray API:", err)
  769. return nil, false, err
  770. }
  771. defer s.xrayAPI.Close()
  772. users, err := s.xrayAPI.GetOnlineUsers()
  773. if err != nil {
  774. if xray.IsUnimplementedErr(err) {
  775. p.SetOnlineAPISupport(xray.OnlineAPIUnsupported)
  776. logger.Info("xray core does not support the online-stats API; falling back to traffic-delta onlines and access-log IP limit")
  777. return nil, false, nil
  778. }
  779. logger.Debug("Failed to fetch Xray online users:", err)
  780. return nil, false, err
  781. }
  782. if p.OnlineAPISupport() == xray.OnlineAPIUnknown {
  783. p.SetOnlineAPISupport(xray.OnlineAPISupported)
  784. logger.Info("xray core supports the online-stats API; using connection-based onlines and access-log-free IP limit")
  785. }
  786. return users, true, nil
  787. }
  788. // BalancerStatus is the live view of one balancer for the panel UI. Running
  789. // is false when the balancer isn't present in the running core (e.g. xray is
  790. // stopped or the balancer hasn't been saved/applied yet).
  791. type BalancerStatus struct {
  792. Tag string `json:"tag"`
  793. Running bool `json:"running"`
  794. Override string `json:"override"`
  795. Selected []string `json:"selected"`
  796. }
  797. // GetBalancersStatus queries the running core for the live state of the
  798. // given balancer tags. Per-tag failures are reported as Running=false rather
  799. // than failing the whole call, so the UI can render saved-but-not-applied
  800. // balancers alongside live ones.
  801. func (s *XrayService) GetBalancersStatus(tags []string) ([]BalancerStatus, error) {
  802. statuses := make([]BalancerStatus, 0, len(tags))
  803. if !s.IsXrayRunning() {
  804. for _, tag := range tags {
  805. statuses = append(statuses, BalancerStatus{Tag: tag})
  806. }
  807. return statuses, nil
  808. }
  809. if err := s.xrayAPI.Init(p.GetAPIPort()); err != nil {
  810. return nil, err
  811. }
  812. defer s.xrayAPI.Close()
  813. for _, tag := range tags {
  814. info, err := s.xrayAPI.GetBalancerInfo(tag)
  815. if err != nil {
  816. logger.Debug("get balancer info [", tag, "] failed:", err)
  817. statuses = append(statuses, BalancerStatus{Tag: tag})
  818. continue
  819. }
  820. statuses = append(statuses, BalancerStatus{
  821. Tag: tag,
  822. Running: true,
  823. Override: info.Override,
  824. Selected: info.Selected,
  825. })
  826. }
  827. return statuses, nil
  828. }
  829. // OverrideBalancer forces a balancer in the running core to use the given
  830. // outbound tag; an empty target clears the override. When target names
  831. // another balancer, the override resolves to the loopback outbound that
  832. // routes traffic through the target balancer via the routing rules.
  833. func (s *XrayService) OverrideBalancer(tag, target string) error {
  834. if !s.IsXrayRunning() {
  835. return errors.New("xray is not running")
  836. }
  837. if target != "" {
  838. resolved, err := s.resolveOverrideTarget(target)
  839. if err != nil {
  840. return err
  841. }
  842. if resolved != "" {
  843. target = resolved
  844. }
  845. }
  846. if err := s.xrayAPI.Init(p.GetAPIPort()); err != nil {
  847. return err
  848. }
  849. defer s.xrayAPI.Close()
  850. return s.xrayAPI.SetBalancerTarget(tag, target)
  851. }
  852. // resolveOverrideTarget checks if target names a balancer and, if so,
  853. // returns the loopback outbound tag that routes to it through the
  854. // routing rules. Returns empty if target is already a concrete outbound.
  855. func (s *XrayService) resolveOverrideTarget(target string) (string, error) {
  856. template, err := s.settingService.GetXrayConfigTemplate()
  857. if err != nil {
  858. return "", err
  859. }
  860. var cfg map[string]any
  861. if err := json.Unmarshal([]byte(template), &cfg); err != nil {
  862. return "", err
  863. }
  864. routing, _ := cfg["routing"].(map[string]any)
  865. if routing == nil {
  866. return "", nil
  867. }
  868. rules, _ := routing["rules"].([]any)
  869. for _, r := range rules {
  870. rule, ok := r.(map[string]any)
  871. if !ok {
  872. continue
  873. }
  874. if rule["balancerTag"] != target {
  875. continue
  876. }
  877. inboundTags, ok := rule["inboundTag"].([]any)
  878. if !ok || len(inboundTags) == 0 {
  879. continue
  880. }
  881. if lbTag, ok := inboundTags[0].(string); ok && strings.HasPrefix(lbTag, "_bl_") {
  882. return lbTag, nil
  883. }
  884. }
  885. return "", nil
  886. }
  887. // TestRoute asks the running core which outbound its router picks for the
  888. // described connection.
  889. func (s *XrayService) TestRoute(req xray.RouteTestRequest) (*xray.RouteTestResult, error) {
  890. if !s.IsXrayRunning() {
  891. return nil, errors.New("xray is not running")
  892. }
  893. if err := s.xrayAPI.Init(p.GetAPIPort()); err != nil {
  894. return nil, err
  895. }
  896. defer s.xrayAPI.Close()
  897. return s.xrayAPI.TestRoute(req)
  898. }
  899. // RestartXray reconciles the running Xray process with the current desired
  900. // config. When isForce is false it first tries to apply the changes through
  901. // the Xray gRPC API without restarting the process (inbounds, outbounds and
  902. // routing rules/balancers are hot-reloadable); only changes the core cannot
  903. // take at runtime — or a force request — stop and restart the process.
  904. func (s *XrayService) RestartXray(isForce bool) error {
  905. lock.Lock()
  906. defer lock.Unlock()
  907. logger.Debug("restart Xray, force:", isForce)
  908. isManuallyStopped.Store(false)
  909. xrayConfig, err := s.GetXrayConfig()
  910. if err != nil {
  911. return err
  912. }
  913. if s.IsXrayRunning() {
  914. configUnchanged := p.GetConfig().Equals(xrayConfig)
  915. if !isForce && configUnchanged && !isNeedXrayRestart.Load() {
  916. logger.Debug("It does not need to restart Xray")
  917. return nil
  918. }
  919. if !isForce && !configUnchanged && s.tryHotApply(xrayConfig) {
  920. logger.Info("Xray config changes applied through the core API, no restart needed")
  921. return nil
  922. }
  923. _ = p.Stop()
  924. }
  925. p = xray.NewProcess(xrayConfig)
  926. result = ""
  927. s.xrayAPI.StatsLastValues = nil
  928. err = p.Start()
  929. if err != nil {
  930. return err
  931. }
  932. return nil
  933. }
  934. // tryHotApply attempts to reconcile the running Xray instance with newCfg
  935. // through the core gRPC API (HandlerService for inbounds/outbounds,
  936. // RoutingService for rules/balancers). It returns true when the running
  937. // instance now matches newCfg; on any failure it returns false and the
  938. // caller falls back to a full process restart, which cleans up whatever was
  939. // partially applied. Callers must hold the package-level lock.
  940. func (s *XrayService) tryHotApply(newCfg *xray.Config) bool {
  941. oldCfg := p.GetConfig()
  942. diff, ok := xray.ComputeHotDiff(oldCfg, newCfg)
  943. if !ok {
  944. logger.Debug("hot apply: config change is not API-applicable, falling back to restart")
  945. return false
  946. }
  947. if diff.Empty() {
  948. p.SetConfig(newCfg)
  949. return true
  950. }
  951. apiPort := p.GetAPIPort()
  952. if apiPort <= 0 {
  953. return false
  954. }
  955. // A dedicated client: s.xrayAPI may be in use by traffic polling on other
  956. // service instances and is reset around restarts.
  957. hotAPI := xray.XrayAPI{}
  958. if err := hotAPI.Init(apiPort); err != nil {
  959. logger.Debug("hot apply: failed to init xray api:", err)
  960. return false
  961. }
  962. defer hotAPI.Close()
  963. // Removals first so changed handlers and port swaps never collide with
  964. // the additions that follow.
  965. for _, u := range diff.RemovedUsers {
  966. if err := hotAPI.RemoveUser(u.Tag, u.Email); err != nil && !xray.IsMissingHandlerErr(err) {
  967. logger.Info("hot apply: remove user [", u.Email, "] from [", u.Tag, "] failed:", err)
  968. return false
  969. }
  970. }
  971. for _, tag := range diff.RemovedInboundTags {
  972. if err := hotAPI.DelInbound(tag); err != nil && !xray.IsMissingHandlerErr(err) {
  973. logger.Info("hot apply: remove inbound [", tag, "] failed:", err)
  974. return false
  975. }
  976. }
  977. for _, tag := range diff.RemovedOutboundTags {
  978. if err := hotAPI.DelOutbound(tag); err != nil && !xray.IsMissingHandlerErr(err) {
  979. logger.Info("hot apply: remove outbound [", tag, "] failed:", err)
  980. return false
  981. }
  982. }
  983. for _, ob := range diff.AddedOutbounds {
  984. if err := addOutboundReconciling(&hotAPI, ob); err != nil {
  985. logger.Info("hot apply: add outbound failed:", err)
  986. return false
  987. }
  988. }
  989. for _, ib := range diff.AddedInbounds {
  990. if err := addInboundReconciling(&hotAPI, ib); err != nil {
  991. logger.Info("hot apply: add inbound failed:", err)
  992. return false
  993. }
  994. }
  995. for _, u := range diff.AddedUsers {
  996. if err := addUserReconciling(&hotAPI, u); err != nil {
  997. logger.Info("hot apply: add user [", u.Email, "] to [", u.Tag, "] failed:", err)
  998. return false
  999. }
  1000. }
  1001. if diff.RoutingConfig != nil {
  1002. if err := hotAPI.ApplyRoutingConfig(diff.RoutingConfig); err != nil {
  1003. logger.Info("hot apply: apply routing config failed:", err)
  1004. return false
  1005. }
  1006. }
  1007. p.SetConfig(newCfg)
  1008. return true
  1009. }
  1010. // addUserReconciling adds a user, and on an email conflict (the user was
  1011. // already applied through the runtime API) replaces the existing user instead.
  1012. func addUserReconciling(api *xray.XrayAPI, u xray.UserOp) error {
  1013. err := api.AddUser(u.Protocol, u.Tag, u.User)
  1014. if err == nil || !xray.IsUserExistsErr(err) {
  1015. return err
  1016. }
  1017. if delErr := api.RemoveUser(u.Tag, u.Email); delErr != nil && !xray.IsMissingHandlerErr(delErr) {
  1018. return delErr
  1019. }
  1020. return api.AddUser(u.Protocol, u.Tag, u.User)
  1021. }
  1022. // addInboundReconciling adds an inbound, and on a tag conflict (the handler
  1023. // was already created through the runtime API while the stored snapshot was
  1024. // stale) replaces the existing handler instead.
  1025. func addInboundReconciling(api *xray.XrayAPI, inbound []byte) error {
  1026. err := api.AddInbound(inbound)
  1027. if err == nil || !xray.IsExistingTagErr(err) {
  1028. return err
  1029. }
  1030. var meta struct {
  1031. Tag string `json:"tag"`
  1032. }
  1033. if jsonErr := json.Unmarshal(inbound, &meta); jsonErr != nil || meta.Tag == "" {
  1034. return err
  1035. }
  1036. if delErr := api.DelInbound(meta.Tag); delErr != nil && !xray.IsMissingHandlerErr(delErr) {
  1037. return delErr
  1038. }
  1039. return api.AddInbound(inbound)
  1040. }
  1041. // addOutboundReconciling mirrors addInboundReconciling for outbounds.
  1042. func addOutboundReconciling(api *xray.XrayAPI, outbound []byte) error {
  1043. err := api.AddOutbound(outbound)
  1044. if err == nil || !xray.IsExistingTagErr(err) {
  1045. return err
  1046. }
  1047. var meta struct {
  1048. Tag string `json:"tag"`
  1049. }
  1050. if jsonErr := json.Unmarshal(outbound, &meta); jsonErr != nil || meta.Tag == "" {
  1051. return err
  1052. }
  1053. if delErr := api.DelOutbound(meta.Tag); delErr != nil && !xray.IsMissingHandlerErr(delErr) {
  1054. return delErr
  1055. }
  1056. return api.AddOutbound(outbound)
  1057. }
  1058. // StopXray stops the running Xray process.
  1059. func (s *XrayService) StopXray() error {
  1060. lock.Lock()
  1061. defer lock.Unlock()
  1062. isManuallyStopped.Store(true)
  1063. logger.Debug("Attempting to stop Xray...")
  1064. if s.IsXrayRunning() {
  1065. return p.Stop()
  1066. }
  1067. return errors.New("xray is not running")
  1068. }
  1069. // SetToNeedRestart marks that Xray needs to be restarted.
  1070. func (s *XrayService) SetToNeedRestart() {
  1071. isNeedXrayRestart.Store(true)
  1072. }
  1073. // GetXrayAPIPort returns the port the local xray process is listening on
  1074. // for its gRPC HandlerService, or 0 when xray isn't currently running.
  1075. // Exposed for the runtime package's LocalRuntime adapter — runtime can't
  1076. // reach into the package-level `p` directly without a service-package
  1077. // import cycle.
  1078. func (s *XrayService) GetXrayAPIPort() int {
  1079. if p == nil || !p.IsRunning() {
  1080. return 0
  1081. }
  1082. return p.GetAPIPort()
  1083. }
  1084. // IsNeedRestartAndSetFalse checks if restart is needed and resets the flag to false.
  1085. func (s *XrayService) IsNeedRestartAndSetFalse() bool {
  1086. return isNeedXrayRestart.CompareAndSwap(true, false)
  1087. }
  1088. // DidXrayCrash checks if Xray crashed by verifying it's not running and wasn't manually stopped.
  1089. func (s *XrayService) DidXrayCrash() bool {
  1090. return !s.IsXrayRunning() && !isManuallyStopped.Load()
  1091. }
  1092. // liftXhttpSessionIDKeys renames the legacy XHTTP session keys
  1093. // (sessionPlacement/sessionKey) to the v26.6.22 #6258 names
  1094. // (sessionIDPlacement/sessionIDKey) inside a streamSettings map. xray-core kept
  1095. // no fallback for the old names, so a config stored before the rename would be
  1096. // silently ignored by the engine. Returns true if it changed anything.
  1097. func liftXhttpSessionIDKeys(stream map[string]any) bool {
  1098. xhttp, ok := stream["xhttpSettings"].(map[string]any)
  1099. if !ok {
  1100. return false
  1101. }
  1102. changed := false
  1103. for legacy, renamed := range map[string]string{
  1104. "sessionPlacement": "sessionIDPlacement",
  1105. "sessionKey": "sessionIDKey",
  1106. } {
  1107. v, has := xhttp[legacy]
  1108. if !has {
  1109. continue
  1110. }
  1111. if _, exists := xhttp[renamed]; !exists {
  1112. xhttp[renamed] = v
  1113. }
  1114. delete(xhttp, legacy)
  1115. changed = true
  1116. }
  1117. return changed
  1118. }
  1119. // liftOutboundsXhttpSessionIDKeys applies liftXhttpSessionIDKeys to every
  1120. // outbound's streamSettings in the raw outbounds array. The original bytes are
  1121. // returned untouched when nothing needs lifting, so an unchanged config never
  1122. // looks modified to the hot-reload diff.
  1123. func liftOutboundsXhttpSessionIDKeys(raw json_util.RawMessage) json_util.RawMessage {
  1124. if len(raw) == 0 {
  1125. return raw
  1126. }
  1127. var outbounds []map[string]any
  1128. if err := json.Unmarshal(raw, &outbounds); err != nil {
  1129. return raw
  1130. }
  1131. changed := false
  1132. for _, ob := range outbounds {
  1133. if stream, ok := ob["streamSettings"].(map[string]any); ok {
  1134. if liftXhttpSessionIDKeys(stream) {
  1135. changed = true
  1136. }
  1137. }
  1138. }
  1139. if !changed {
  1140. return raw
  1141. }
  1142. if rewritten, err := json.Marshal(outbounds); err == nil {
  1143. return rewritten
  1144. }
  1145. return raw
  1146. }