hot_diff.go 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670
  1. package xray
  2. import (
  3. "bytes"
  4. "encoding/json"
  5. "maps"
  6. "github.com/mhsanaei/3x-ui/v3/internal/logger"
  7. "github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
  8. )
  9. // HotDiff describes the gRPC API operations needed to bring a running Xray
  10. // instance from one generated config to another without restarting the
  11. // process. It only covers the sections Xray can reload at runtime: inbounds,
  12. // outbounds and routing rules/balancers.
  13. type HotDiff struct {
  14. RemovedInboundTags []string
  15. AddedInbounds [][]byte
  16. RemovedUsers []UserOp
  17. AddedUsers []UserOp
  18. // DroppedClients are emails an inbound that survives the change stopped
  19. // serving, including the protocols diffInboundUsers will not diff.
  20. DroppedClients []UserOp
  21. RemovedOutboundTags []string
  22. AddedOutbounds [][]byte
  23. RoutingConfig []byte // full new routing section; nil when unchanged
  24. }
  25. // UserOp is a per-user AlterInbound operation; User is nil for removals.
  26. type UserOp struct {
  27. Tag string
  28. Protocol string
  29. Email string
  30. User map[string]any
  31. }
  32. // DropsUsers reports users removed without being re-added under the same tag:
  33. // a disable or a delete, where an edit re-adds the email with new values.
  34. func (d *HotDiff) DropsUsers() bool {
  35. if len(d.DroppedClients) > 0 {
  36. return true
  37. }
  38. if len(d.RemovedUsers) == 0 {
  39. return false
  40. }
  41. readded := make(map[string]struct{}, len(d.AddedUsers))
  42. for _, u := range d.AddedUsers {
  43. readded[u.Tag+"\x00"+u.Email] = struct{}{}
  44. }
  45. for _, u := range d.RemovedUsers {
  46. if _, ok := readded[u.Tag+"\x00"+u.Email]; !ok {
  47. return true
  48. }
  49. }
  50. return false
  51. }
  52. // Empty reports whether the diff contains no operations.
  53. func (d *HotDiff) Empty() bool {
  54. return len(d.RemovedInboundTags) == 0 &&
  55. len(d.AddedInbounds) == 0 &&
  56. len(d.RemovedUsers) == 0 &&
  57. len(d.AddedUsers) == 0 &&
  58. len(d.RemovedOutboundTags) == 0 &&
  59. len(d.AddedOutbounds) == 0 &&
  60. d.RoutingConfig == nil
  61. }
  62. // ComputeHotDiff compares two generated configs and returns the API operations
  63. // that transform a running instance from oldCfg to newCfg. ok is false when
  64. // the change touches anything that has no runtime reload API (log, dns,
  65. // policy, ...) and therefore requires a full process restart.
  66. func ComputeHotDiff(oldCfg, newCfg *Config) (*HotDiff, bool) {
  67. if oldCfg == nil || newCfg == nil {
  68. return nil, false
  69. }
  70. // Sections without a reload API must be semantically identical.
  71. // Comparison is whitespace-insensitive: a template save that merely
  72. // reformats the JSON (frontend textarea, API clients) must not be
  73. // mistaken for a real change that forces a restart.
  74. static := []struct {
  75. name string
  76. old, new json_util.RawMessage
  77. }{
  78. {"log", oldCfg.LogConfig, newCfg.LogConfig},
  79. {"dns", oldCfg.DNSConfig, newCfg.DNSConfig},
  80. {"transport", oldCfg.Transport, newCfg.Transport},
  81. {"policy", oldCfg.Policy, newCfg.Policy},
  82. {"api", oldCfg.API, newCfg.API},
  83. {"stats", oldCfg.Stats, newCfg.Stats},
  84. {"reverse", oldCfg.Reverse, newCfg.Reverse},
  85. {"fakedns", oldCfg.FakeDNS, newCfg.FakeDNS},
  86. {"observatory", oldCfg.Observatory, newCfg.Observatory},
  87. {"burstObservatory", oldCfg.BurstObservatory, newCfg.BurstObservatory},
  88. {"metrics", oldCfg.Metrics, newCfg.Metrics},
  89. {"geodata", oldCfg.Geodata, newCfg.Geodata},
  90. {"env", oldCfg.Env, newCfg.Env},
  91. }
  92. for _, section := range static {
  93. if !rawEqualNormalized(section.old, section.new) {
  94. logger.Debug("hot diff: section [", section.name, "] changed and has no reload API")
  95. return nil, false
  96. }
  97. }
  98. diff := &HotDiff{}
  99. if ok := diffInbounds(oldCfg, newCfg, diff); !ok {
  100. logger.Debug("hot diff: inbound change is not API-applicable")
  101. return nil, false
  102. }
  103. if ok := diffOutbounds(oldCfg, newCfg, diff); !ok {
  104. logger.Debug("hot diff: outbound change is not API-applicable (default outbound or tags)")
  105. return nil, false
  106. }
  107. if ok := diffRouting(oldCfg, newCfg, diff); !ok {
  108. logger.Debug("hot diff: routing change is not API-applicable (domainStrategy or section shape)")
  109. return nil, false
  110. }
  111. return diff, true
  112. }
  113. // diffInbounds fills diff with inbound removals/additions (a changed inbound
  114. // becomes remove+add). The api inbound carries the gRPC server the panel is
  115. // talking through, so any change touching it forces a restart.
  116. func diffInbounds(oldCfg, newCfg *Config, diff *HotDiff) bool {
  117. oldByTag, ok := inboundsByTag(oldCfg.InboundConfigs)
  118. if !ok {
  119. return false
  120. }
  121. newByTag, ok := inboundsByTag(newCfg.InboundConfigs)
  122. if !ok {
  123. return false
  124. }
  125. apiTag := apiTagFromConfig(newCfg.API)
  126. for i := range oldCfg.InboundConfigs {
  127. oldIb := &oldCfg.InboundConfigs[i]
  128. newIb, exists := newByTag[oldIb.Tag]
  129. if exists && inboundEqualNormalized(oldIb, newIb) {
  130. continue
  131. }
  132. if oldIb.Tag == apiTag || oldIb.Tag == "api" {
  133. return false
  134. }
  135. if exists && (inboundHasReverseClient(oldIb) || inboundHasReverseClient(newIb)) {
  136. logger.Debug("hot diff: inbound [", oldIb.Tag, "] carries a reverse-tagged client, forcing a full restart instead of a hot swap")
  137. return false
  138. }
  139. if exists {
  140. diff.DroppedClients = append(diff.DroppedClients, droppedClients(oldIb, newIb)...)
  141. }
  142. if exists && diffInboundUsers(oldIb, newIb, diff) {
  143. continue
  144. }
  145. if exists && (inboundUsesReality(oldIb) || inboundUsesReality(newIb)) {
  146. logger.Debug("hot diff: inbound [", oldIb.Tag, "] REALITY configuration changed; a gRPC remove+add does not reliably rebuild the REALITY authenticator, forcing a full restart")
  147. return false
  148. }
  149. if exists && (inboundUsesTproxy(oldIb) || inboundUsesTproxy(newIb)) {
  150. logger.Debug("hot diff: inbound [", oldIb.Tag, "] is a TPROXY target; a gRPC add reports success but does not reliably bind a working listener, forcing a full restart instead of a hot swap")
  151. return false
  152. }
  153. if exists && (inboundUsesSocksAccounts(oldIb) || inboundUsesSocksAccounts(newIb)) {
  154. logger.Debug("hot diff: inbound [", oldIb.Tag, "] is a password-auth SOCKS5 inbound (e.g. internal/amneziawgnet's per-peer relay); a gRPC remove+add reports success but was observed in production to silently drop an account, forcing a full restart instead of a hot swap")
  155. return false
  156. }
  157. diff.RemovedInboundTags = append(diff.RemovedInboundTags, oldIb.Tag)
  158. if exists {
  159. raw, err := json.Marshal(newIb)
  160. if err != nil {
  161. return false
  162. }
  163. diff.AddedInbounds = append(diff.AddedInbounds, raw)
  164. }
  165. }
  166. for i := range newCfg.InboundConfigs {
  167. newIb := &newCfg.InboundConfigs[i]
  168. if _, exists := oldByTag[newIb.Tag]; exists {
  169. continue
  170. }
  171. if newIb.Tag == apiTag || newIb.Tag == "api" {
  172. return false
  173. }
  174. if inboundUsesTproxy(newIb) {
  175. logger.Debug("hot diff: new inbound [", newIb.Tag, "] is a TPROXY target (e.g. internal/amneziawg's Xray egress bridge); a gRPC add reports success but does not reliably bind a working listener, forcing a full restart instead of a hot add")
  176. return false
  177. }
  178. if inboundUsesSocksAccounts(newIb) {
  179. logger.Debug("hot diff: new inbound [", newIb.Tag, "] is a password-auth SOCKS5 inbound (e.g. internal/amneziawgnet's per-peer relay); forcing a full restart instead of a hot add, same reasoning as the existing-inbound case above")
  180. return false
  181. }
  182. raw, err := json.Marshal(newIb)
  183. if err != nil {
  184. return false
  185. }
  186. diff.AddedInbounds = append(diff.AddedInbounds, raw)
  187. }
  188. return true
  189. }
  190. // droppedClients lists the emails an inbound present in both configs stopped
  191. // serving, whatever its protocol: settings.clients is the shape they all share.
  192. func droppedClients(oldIb, newIb *InboundConfig) []UserOp {
  193. oldClients, _, ok := splitSettingsClients(oldIb.Settings)
  194. if !ok {
  195. return nil
  196. }
  197. newClients, _, ok := splitSettingsClients(newIb.Settings)
  198. if !ok {
  199. return nil
  200. }
  201. var dropped []UserOp
  202. for email := range oldClients {
  203. if _, still := newClients[email]; !still {
  204. dropped = append(dropped, UserOp{Tag: newIb.Tag, Protocol: newIb.Protocol, Email: email})
  205. }
  206. }
  207. return dropped
  208. }
  209. // InboundUserOps returns the per-user ops that bring a running inbound from oldIb
  210. // to newIb; ok is false when only rebuilding its handler can apply the change.
  211. func InboundUserOps(oldIb, newIb *InboundConfig) (*HotDiff, bool) {
  212. diff := &HotDiff{}
  213. if inboundEqualNormalized(oldIb, newIb) {
  214. return diff, true
  215. }
  216. if !diffInboundUsers(oldIb, newIb, diff) {
  217. return nil, false
  218. }
  219. return diff, true
  220. }
  221. var userDiffableProtocols = map[string]struct{}{"vless": {}, "vmess": {}, "trojan": {}, "hysteria": {}, "masque": {}, "shadowsocks": {}}
  222. // diffInboundUsers emits per-user AlterInbound ops when two same-tag inbounds
  223. // differ only in settings.clients, so the handler (and its listener) survives.
  224. func diffInboundUsers(oldIb, newIb *InboundConfig, diff *HotDiff) bool {
  225. if oldIb.Port != newIb.Port || oldIb.Protocol != newIb.Protocol || oldIb.Tag != newIb.Tag {
  226. return false
  227. }
  228. if _, ok := userDiffableProtocols[oldIb.Protocol]; !ok {
  229. return false
  230. }
  231. if !rawEqualNormalized(oldIb.Listen, newIb.Listen) ||
  232. !rawEqualNormalized(oldIb.StreamSettings, newIb.StreamSettings) ||
  233. !rawEqualNormalized(oldIb.Sniffing, newIb.Sniffing) {
  234. return false
  235. }
  236. oldClients, oldRest, ok := splitSettingsClients(oldIb.Settings)
  237. if !ok {
  238. return false
  239. }
  240. newClients, newRest, ok := splitSettingsClients(newIb.Settings)
  241. if !ok {
  242. return false
  243. }
  244. if !bytes.Equal(oldRest, newRest) {
  245. return false
  246. }
  247. cipher := ""
  248. if newIb.Protocol == "shadowsocks" {
  249. cipher = shadowsocksSettingsMethod(newRest)
  250. }
  251. for email, oldC := range oldClients {
  252. newC, exists := newClients[email]
  253. if exists && bytes.Equal(oldC.norm, newC.norm) {
  254. continue
  255. }
  256. diff.RemovedUsers = append(diff.RemovedUsers, UserOp{Tag: oldIb.Tag, Protocol: oldIb.Protocol, Email: email})
  257. if exists {
  258. diff.AddedUsers = append(diff.AddedUsers, UserOp{Tag: oldIb.Tag, Protocol: oldIb.Protocol, Email: email, User: userWithCipher(newC.user, cipher)})
  259. }
  260. }
  261. for email, newC := range newClients {
  262. if _, exists := oldClients[email]; !exists {
  263. diff.AddedUsers = append(diff.AddedUsers, UserOp{Tag: oldIb.Tag, Protocol: oldIb.Protocol, Email: email, User: userWithCipher(newC.user, cipher)})
  264. }
  265. }
  266. return true
  267. }
  268. // shadowsocksSettingsMethod reads the inbound-level method: 2022 clients carry
  269. // none, and AddUser needs it to pick the account type the running inbound expects.
  270. func shadowsocksSettingsMethod(rest []byte) string {
  271. var settings struct {
  272. Method string `json:"method"`
  273. }
  274. if json.Unmarshal(rest, &settings) != nil {
  275. return ""
  276. }
  277. return settings.Method
  278. }
  279. func userWithCipher(user map[string]any, cipher string) map[string]any {
  280. if cipher == "" {
  281. return user
  282. }
  283. withCipher := maps.Clone(user)
  284. withCipher["cipher"] = cipher
  285. return withCipher
  286. }
  287. type clientEntry struct {
  288. user map[string]any
  289. norm []byte
  290. }
  291. // splitSettingsClients indexes settings.clients by email and returns the rest of
  292. // the settings in canonical form; ok is false when a client has no unique email.
  293. func splitSettingsClients(raw json_util.RawMessage) (map[string]clientEntry, []byte, bool) {
  294. if len(raw) == 0 {
  295. return nil, nil, false
  296. }
  297. settings := map[string]any{}
  298. decoder := json.NewDecoder(bytes.NewReader(raw))
  299. decoder.UseNumber()
  300. if err := decoder.Decode(&settings); err != nil {
  301. return nil, nil, false
  302. }
  303. clientsRaw, hasClients := settings["clients"].([]any)
  304. if !hasClients {
  305. return nil, nil, false
  306. }
  307. clients := make(map[string]clientEntry, len(clientsRaw))
  308. for _, c := range clientsRaw {
  309. obj, ok := c.(map[string]any)
  310. if !ok {
  311. return nil, nil, false
  312. }
  313. email, _ := obj["email"].(string)
  314. if email == "" {
  315. return nil, nil, false
  316. }
  317. if _, dup := clients[email]; dup {
  318. return nil, nil, false
  319. }
  320. norm, err := json.Marshal(obj)
  321. if err != nil {
  322. return nil, nil, false
  323. }
  324. clients[email] = clientEntry{user: obj, norm: norm}
  325. }
  326. delete(settings, "clients")
  327. rest, err := json.Marshal(settings)
  328. if err != nil {
  329. return nil, nil, false
  330. }
  331. return clients, rest, true
  332. }
  333. func inboundUsesReality(ib *InboundConfig) bool {
  334. if ib == nil || len(ib.StreamSettings) == 0 {
  335. return false
  336. }
  337. var stream struct {
  338. Security string `json:"security"`
  339. }
  340. if err := json.Unmarshal(ib.StreamSettings, &stream); err != nil {
  341. return false
  342. }
  343. return stream.Security == "reality"
  344. }
  345. // inboundUsesTproxy: a sockopt.tproxy inbound (the tunnel protocol's TProxy
  346. // mode) hot-adds over gRPC "successfully" but binds no listener — restart.
  347. func inboundUsesTproxy(ib *InboundConfig) bool {
  348. if ib == nil || len(ib.StreamSettings) == 0 {
  349. return false
  350. }
  351. var stream struct {
  352. Sockopt struct {
  353. Tproxy string `json:"tproxy"`
  354. } `json:"sockopt"`
  355. }
  356. if err := json.Unmarshal(ib.StreamSettings, &stream); err != nil {
  357. return false
  358. }
  359. return stream.Sockopt.Tproxy != "" && stream.Sockopt.Tproxy != "off"
  360. }
  361. // inboundUsesSocksAccounts reports whether an inbound is a password-auth
  362. // SOCKS5 inbound with one or more named accounts -- the shape
  363. // internal/amneziawgnet's per-inbound relay (internal/web/service/xray.go's
  364. // injectAmneziawgnetSocks) is the only generator of in this fork; every
  365. // other SOCKS5 bridge this fork builds (panel egress, per-node egress,
  366. // mtproto egress) uses "noauth" with no per-account identity at all, so this
  367. // check can't accidentally rope in one of those lower-churn bridges.
  368. //
  369. // Real production incident, not a theoretical concern: a single client
  370. // edit under an AmneziaWG inbound left this inbound's settings unchanged in
  371. // every way relevant to accounts.user was already correct in the freshly
  372. // regenerated config, yet the account for a peer whose email contained
  373. // non-ASCII characters silently vanished from the running Xray process
  374. // after a gRPC remove+add hot swap -- while a full process restart (reading
  375. // the same JSON straight from disk) always produced the correct account
  376. // list. socks isn't in userDiffableProtocols (that only covers vless/vmess/
  377. // trojan, which use a wholly different clients+email shape, not
  378. // accounts+user), so without this check any settings drift on this inbound
  379. // -- even one unrelated to the account list itself -- falls through to the
  380. // generic remove+add path and can reproduce the same silent drop. Forcing a
  381. // full restart here is the same defensive choice already made above for
  382. // REALITY and TPROXY.
  383. func inboundUsesSocksAccounts(ib *InboundConfig) bool {
  384. if ib == nil || ib.Protocol != "socks" || len(ib.Settings) == 0 {
  385. return false
  386. }
  387. var settings struct {
  388. Auth string `json:"auth"`
  389. }
  390. if err := json.Unmarshal(ib.Settings, &settings); err != nil {
  391. return false
  392. }
  393. return settings.Auth == "password"
  394. }
  395. func inboundHasReverseClient(ib *InboundConfig) bool {
  396. if ib == nil {
  397. return false
  398. }
  399. var settings struct {
  400. Clients []struct {
  401. Reverse json.RawMessage `json:"reverse"`
  402. } `json:"clients"`
  403. }
  404. if err := json.Unmarshal(ib.Settings, &settings); err != nil {
  405. return false
  406. }
  407. for _, c := range settings.Clients {
  408. if len(c.Reverse) == 0 {
  409. continue
  410. }
  411. var tag any
  412. if err := json.Unmarshal(c.Reverse, &tag); err != nil || tag == nil {
  413. continue
  414. }
  415. return true
  416. }
  417. return false
  418. }
  419. // diffOutbounds fills diff with outbound removals/additions keyed by tag.
  420. // The first outbound is xray's default handler and the API can only append,
  421. // so any change to its identity or content forces a restart. Reordering of
  422. // the remaining outbounds is ignored — routing addresses them by tag.
  423. func diffOutbounds(oldCfg, newCfg *Config, diff *HotDiff) bool {
  424. oldOut, ok := parseOutbounds(oldCfg.OutboundConfigs)
  425. if !ok {
  426. return false
  427. }
  428. newOut, ok := parseOutbounds(newCfg.OutboundConfigs)
  429. if !ok {
  430. return false
  431. }
  432. if (len(oldOut) == 0) != (len(newOut) == 0) {
  433. return false
  434. }
  435. if len(oldOut) > 0 {
  436. if oldOut[0].tag != newOut[0].tag || !bytes.Equal(oldOut[0].norm, newOut[0].norm) {
  437. return false
  438. }
  439. }
  440. oldByTag := make(map[string]outboundEntry, len(oldOut))
  441. for _, e := range oldOut {
  442. oldByTag[e.tag] = e
  443. }
  444. newByTag := make(map[string]outboundEntry, len(newOut))
  445. for _, e := range newOut {
  446. newByTag[e.tag] = e
  447. }
  448. for _, oldE := range oldOut {
  449. newE, exists := newByTag[oldE.tag]
  450. if exists && bytes.Equal(oldE.norm, newE.norm) {
  451. continue
  452. }
  453. diff.RemovedOutboundTags = append(diff.RemovedOutboundTags, oldE.tag)
  454. if exists {
  455. diff.AddedOutbounds = append(diff.AddedOutbounds, newE.raw)
  456. }
  457. }
  458. for _, newE := range newOut {
  459. if _, exists := oldByTag[newE.tag]; !exists {
  460. diff.AddedOutbounds = append(diff.AddedOutbounds, newE.raw)
  461. }
  462. }
  463. return true
  464. }
  465. // diffRouting decides whether the routing change is limited to rules and
  466. // balancers — the only parts RoutingService.AddRule can replace at runtime.
  467. // domainStrategy/domainMatcher and any other key in the section are fixed at
  468. // process start.
  469. func diffRouting(oldCfg, newCfg *Config, diff *HotDiff) bool {
  470. if bytes.Equal(oldCfg.RouterConfig, newCfg.RouterConfig) {
  471. return true
  472. }
  473. // No routing section at start likely means no router feature (and no
  474. // RoutingService) in the running instance — only a restart can add it.
  475. if len(oldCfg.RouterConfig) == 0 || len(newCfg.RouterConfig) == 0 {
  476. return false
  477. }
  478. oldRest, ok := routingWithoutReloadable(oldCfg.RouterConfig)
  479. if !ok {
  480. return false
  481. }
  482. newRest, ok := routingWithoutReloadable(newCfg.RouterConfig)
  483. if !ok {
  484. return false
  485. }
  486. if !bytes.Equal(oldRest, newRest) {
  487. return false
  488. }
  489. diff.RoutingConfig = newCfg.RouterConfig
  490. return true
  491. }
  492. // routingWithoutReloadable returns the routing section normalized with the
  493. // runtime-reloadable keys removed, for comparing the restart-only remainder.
  494. func routingWithoutReloadable(raw []byte) ([]byte, bool) {
  495. parsed := map[string]any{}
  496. if len(raw) > 0 {
  497. decoder := json.NewDecoder(bytes.NewReader(raw))
  498. decoder.UseNumber()
  499. if err := decoder.Decode(&parsed); err != nil {
  500. return nil, false
  501. }
  502. }
  503. delete(parsed, "rules")
  504. delete(parsed, "balancers")
  505. out, err := json.Marshal(parsed)
  506. if err != nil {
  507. return nil, false
  508. }
  509. return out, true
  510. }
  511. // inboundEqualNormalized compares two inbounds ignoring JSON formatting in
  512. // their raw sections, so a reformatted template does not read as a changed
  513. // inbound.
  514. func inboundEqualNormalized(a, b *InboundConfig) bool {
  515. return a.Port == b.Port &&
  516. a.Protocol == b.Protocol &&
  517. a.Tag == b.Tag &&
  518. rawEqualNormalized(a.Listen, b.Listen) &&
  519. rawEqualNormalized(a.Settings, b.Settings) &&
  520. rawEqualNormalized(a.StreamSettings, b.StreamSettings) &&
  521. rawEqualNormalized(a.Sniffing, b.Sniffing)
  522. }
  523. // rawEqualNormalized reports whether two raw JSON values are semantically
  524. // equal: whitespace, object key order and an explicit `null` versus an
  525. // absent section are all ignored. UI editors rebuild objects on save (new
  526. // key order) and emit `null` for switched-off sections — none of that is a
  527. // reason to restart the core. Number precision is preserved via json.Number,
  528. // so genuinely different values never compare equal. Unparsable values only
  529. // compare equal byte-for-byte.
  530. func rawEqualNormalized(a, b json_util.RawMessage) bool {
  531. if bytes.Equal(a, b) {
  532. return true
  533. }
  534. na, ok := canonicalJSON(a)
  535. if !ok {
  536. return false
  537. }
  538. nb, ok := canonicalJSON(b)
  539. if !ok {
  540. return false
  541. }
  542. return bytes.Equal(na, nb)
  543. }
  544. // canonicalJSON renders a JSON value in canonical form: sorted object keys,
  545. // no insignificant whitespace, exact number digits (json.Number). Empty
  546. // input and JSON null both canonicalize to nil.
  547. func canonicalJSON(raw json_util.RawMessage) ([]byte, bool) {
  548. if len(raw) == 0 {
  549. return nil, true
  550. }
  551. decoder := json.NewDecoder(bytes.NewReader(raw))
  552. decoder.UseNumber()
  553. var value any
  554. if err := decoder.Decode(&value); err != nil {
  555. return nil, false
  556. }
  557. if value == nil {
  558. return nil, true
  559. }
  560. out, err := json.Marshal(value)
  561. if err != nil {
  562. return nil, false
  563. }
  564. return out, true
  565. }
  566. // inboundsByTag indexes inbounds by tag; ok is false when a tag is empty or
  567. // duplicated, since such handlers can't be addressed through the API.
  568. func inboundsByTag(inbounds []InboundConfig) (map[string]*InboundConfig, bool) {
  569. byTag := make(map[string]*InboundConfig, len(inbounds))
  570. for i := range inbounds {
  571. tag := inbounds[i].Tag
  572. if tag == "" {
  573. return nil, false
  574. }
  575. if _, dup := byTag[tag]; dup {
  576. return nil, false
  577. }
  578. byTag[tag] = &inbounds[i]
  579. }
  580. return byTag, true
  581. }
  582. type outboundEntry struct {
  583. tag string
  584. raw []byte // original JSON, used for AddOutbound
  585. norm []byte // canonical JSON, used for change detection
  586. }
  587. // parseOutbounds splits the outbounds array into per-entry raw/normalized
  588. // JSON. ok is false when the array is unparsable or an entry has an empty or
  589. // duplicate tag — those can't be addressed through the API.
  590. func parseOutbounds(raw json_util.RawMessage) ([]outboundEntry, bool) {
  591. if len(raw) == 0 {
  592. return nil, true
  593. }
  594. var elems []json.RawMessage
  595. if err := json.Unmarshal(raw, &elems); err != nil {
  596. return nil, false
  597. }
  598. entries := make([]outboundEntry, 0, len(elems))
  599. seen := make(map[string]struct{}, len(elems))
  600. for _, elem := range elems {
  601. var meta struct {
  602. Tag string `json:"tag"`
  603. }
  604. if err := json.Unmarshal(elem, &meta); err != nil {
  605. return nil, false
  606. }
  607. if meta.Tag == "" {
  608. return nil, false
  609. }
  610. if _, dup := seen[meta.Tag]; dup {
  611. return nil, false
  612. }
  613. seen[meta.Tag] = struct{}{}
  614. norm, ok := canonicalJSON(json_util.RawMessage(elem))
  615. if !ok {
  616. return nil, false
  617. }
  618. entries = append(entries, outboundEntry{tag: meta.Tag, raw: elem, norm: norm})
  619. }
  620. return entries, true
  621. }
  622. // apiTagFromConfig extracts api.tag from the api section, defaulting to "api".
  623. func apiTagFromConfig(api json_util.RawMessage) string {
  624. var parsed struct {
  625. Tag string `json:"tag"`
  626. }
  627. if len(api) > 0 && json.Unmarshal(api, &parsed) == nil && parsed.Tag != "" {
  628. return parsed.Tag
  629. }
  630. return "api"
  631. }