hot_diff_test.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345
  1. package xray
  2. import (
  3. "os"
  4. "strings"
  5. "testing"
  6. xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
  7. "github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
  8. "github.com/op/go-logging"
  9. )
  10. func TestMain(m *testing.M) {
  11. // ComputeHotDiff logs the section that blocks a hot apply; the package
  12. // logger must exist before any test exercises a blocked path.
  13. xuilogger.InitLogger(logging.ERROR)
  14. os.Exit(m.Run())
  15. }
  16. func makeHotConfig() *Config {
  17. return &Config{
  18. LogConfig: json_util.RawMessage(`{"loglevel":"warning"}`),
  19. RouterConfig: json_util.RawMessage(`{"domainStrategy":"AsIs","rules":[{"type":"field","inboundTag":["api"],"outboundTag":"api"}]}`),
  20. OutboundConfigs: json_util.RawMessage(`[{"protocol":"freedom","tag":"direct"},{"protocol":"blackhole","tag":"blocked"}]`),
  21. Policy: json_util.RawMessage(`{}`),
  22. API: json_util.RawMessage(`{"services":["HandlerService","StatsService","RoutingService"],"tag":"api"}`),
  23. Stats: json_util.RawMessage(`{}`),
  24. Metrics: json_util.RawMessage(`{}`),
  25. InboundConfigs: []InboundConfig{
  26. {
  27. Port: 62789,
  28. Protocol: "tunnel",
  29. Tag: "api",
  30. Listen: json_util.RawMessage(`"127.0.0.1"`),
  31. Settings: json_util.RawMessage(`{}`),
  32. },
  33. {
  34. Port: 1080,
  35. Protocol: "vless",
  36. Tag: "inbound-1080",
  37. Listen: json_util.RawMessage(`"0.0.0.0"`),
  38. Settings: json_util.RawMessage(`{"clients":[]}`),
  39. },
  40. },
  41. }
  42. }
  43. func TestComputeHotDiff_NoChanges(t *testing.T) {
  44. diff, ok := ComputeHotDiff(makeHotConfig(), makeHotConfig())
  45. if !ok {
  46. t.Fatal("identical configs must be hot-appliable")
  47. }
  48. if !diff.Empty() {
  49. t.Fatalf("identical configs must produce an empty diff, got %+v", diff)
  50. }
  51. }
  52. func TestComputeHotDiff_FormattingOnlyChangeIsEmptyDiff(t *testing.T) {
  53. oldCfg := makeHotConfig()
  54. newCfg := makeHotConfig()
  55. // Reformat every section the way a frontend textarea save would.
  56. newCfg.LogConfig = json_util.RawMessage("{\n \"loglevel\": \"warning\"\n}")
  57. newCfg.Policy = json_util.RawMessage("{ }")
  58. newCfg.API = json_util.RawMessage("{\n \"services\": [\"HandlerService\", \"StatsService\", \"RoutingService\"],\n \"tag\": \"api\"\n}")
  59. newCfg.OutboundConfigs = json_util.RawMessage("[\n {\"protocol\": \"freedom\", \"tag\": \"direct\"},\n {\"protocol\": \"blackhole\", \"tag\": \"blocked\"}\n]")
  60. newCfg.InboundConfigs[1].Settings = json_util.RawMessage("{\n \"clients\": []\n}")
  61. diff, ok := ComputeHotDiff(oldCfg, newCfg)
  62. if !ok {
  63. t.Fatal("formatting-only change must be hot-appliable")
  64. }
  65. if len(diff.RemovedInboundTags) != 0 || len(diff.AddedInbounds) != 0 ||
  66. len(diff.RemovedOutboundTags) != 0 || len(diff.AddedOutbounds) != 0 {
  67. t.Fatalf("formatting-only change must produce no handler ops, got %+v", diff)
  68. }
  69. }
  70. func TestComputeHotDiff_CanonicalEquality(t *testing.T) {
  71. // Key reorder in a static section (the DNS editor rebuilds the object on
  72. // save) must not read as a change.
  73. oldCfg := makeHotConfig()
  74. oldCfg.DNSConfig = json_util.RawMessage(`{"servers":["1.1.1.1"],"queryStrategy":"UseIP","tag":"dns-in"}`)
  75. newCfg := makeHotConfig()
  76. newCfg.DNSConfig = json_util.RawMessage(`{"tag":"dns-in","queryStrategy":"UseIP","servers":["1.1.1.1"]}`)
  77. diff, ok := ComputeHotDiff(oldCfg, newCfg)
  78. if !ok || !diff.Empty() {
  79. t.Fatalf("dns key reorder must be an empty hot diff, ok=%v diff=%+v", ok, diff)
  80. }
  81. // Explicit null and an absent section are the same thing.
  82. newCfg = makeHotConfig()
  83. newCfg.FakeDNS = json_util.RawMessage(`null`)
  84. diff, ok = ComputeHotDiff(makeHotConfig(), newCfg)
  85. if !ok || !diff.Empty() {
  86. t.Fatalf("fakedns null vs absent must be an empty hot diff, ok=%v diff=%+v", ok, diff)
  87. }
  88. // A real DNS change still forces a restart — there is no reload API.
  89. newCfg = makeHotConfig()
  90. newCfg.DNSConfig = json_util.RawMessage(`{"servers":["8.8.8.8"]}`)
  91. if _, ok := ComputeHotDiff(makeHotConfig(), newCfg); ok {
  92. t.Fatal("real dns change must force a restart")
  93. }
  94. // Large integers keep full precision during normalization: two values
  95. // that only differ past float64 precision must still read as a change.
  96. oldCfg = makeHotConfig()
  97. oldCfg.Policy = json_util.RawMessage(`{"big":9007199254740993}`)
  98. newCfg = makeHotConfig()
  99. newCfg.Policy = json_util.RawMessage(`{"big":9007199254740992}`)
  100. if _, ok := ComputeHotDiff(oldCfg, newCfg); ok {
  101. t.Fatal("values differing past float64 precision must not compare equal")
  102. }
  103. // Reordered keys inside the first (default) outbound must not force a
  104. // restart — the form editor rebuilds the object on save.
  105. oldCfg = makeHotConfig()
  106. oldCfg.OutboundConfigs = json_util.RawMessage(`[{"protocol":"freedom","settings":{"domainStrategy":"AsIs"},"tag":"direct"},{"protocol":"blackhole","tag":"blocked"}]`)
  107. newCfg = makeHotConfig()
  108. newCfg.OutboundConfigs = json_util.RawMessage(`[{"tag":"direct","settings":{"domainStrategy":"AsIs"},"protocol":"freedom"},{"protocol":"blackhole","tag":"blocked"}]`)
  109. diff, ok = ComputeHotDiff(oldCfg, newCfg)
  110. if !ok || !diff.Empty() {
  111. t.Fatalf("first outbound key reorder must be an empty hot diff, ok=%v diff=%+v", ok, diff)
  112. }
  113. }
  114. func TestComputeHotDiff_StaticSectionChangeNeedsRestart(t *testing.T) {
  115. newCfg := makeHotConfig()
  116. newCfg.LogConfig = json_util.RawMessage(`{"loglevel":"debug"}`)
  117. if _, ok := ComputeHotDiff(makeHotConfig(), newCfg); ok {
  118. t.Fatal("log change must force a restart")
  119. }
  120. newCfg = makeHotConfig()
  121. newCfg.DNSConfig = json_util.RawMessage(`{"servers":["1.1.1.1"]}`)
  122. if _, ok := ComputeHotDiff(makeHotConfig(), newCfg); ok {
  123. t.Fatal("dns change must force a restart")
  124. }
  125. newCfg = makeHotConfig()
  126. newCfg.Observatory = json_util.RawMessage(`{"subjectSelector":["wg"]}`)
  127. if _, ok := ComputeHotDiff(makeHotConfig(), newCfg); ok {
  128. t.Fatal("observatory change must force a restart")
  129. }
  130. newCfg = makeHotConfig()
  131. newCfg.Env = json_util.RawMessage(`{"XRAY_DNS_PATH":"/tmp/dns"}`)
  132. if _, ok := ComputeHotDiff(makeHotConfig(), newCfg); ok {
  133. t.Fatal("env change must force a restart: env vars are read only at process start")
  134. }
  135. }
  136. func TestComputeHotDiff_InboundAddRemoveChange(t *testing.T) {
  137. oldCfg := makeHotConfig()
  138. newCfg := makeHotConfig()
  139. // change existing beyond the clients list, so no user-level shortcut applies
  140. newCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[],"decryption":"none"}`)
  141. // add new
  142. newCfg.InboundConfigs = append(newCfg.InboundConfigs, InboundConfig{
  143. Port: 2080, Protocol: "vmess", Tag: "inbound-2080",
  144. Settings: json_util.RawMessage(`{}`),
  145. })
  146. diff, ok := ComputeHotDiff(oldCfg, newCfg)
  147. if !ok {
  148. t.Fatal("inbound-only change must be hot-appliable")
  149. }
  150. if len(diff.RemovedInboundTags) != 1 || diff.RemovedInboundTags[0] != "inbound-1080" {
  151. t.Fatalf("expected changed inbound to be removed, got %v", diff.RemovedInboundTags)
  152. }
  153. if len(diff.AddedInbounds) != 2 {
  154. t.Fatalf("expected re-add + new add, got %d", len(diff.AddedInbounds))
  155. }
  156. if diff.RoutingConfig != nil || len(diff.AddedOutbounds) != 0 || len(diff.RemovedOutboundTags) != 0 {
  157. t.Fatalf("unexpected non-inbound operations: %+v", diff)
  158. }
  159. }
  160. func TestComputeHotDiff_ClientOnlyChangeUsesUserOps(t *testing.T) {
  161. oldCfg := makeHotConfig()
  162. oldCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[{"email":"a","id":"uuid-a"},{"email":"b","id":"uuid-b"}],"decryption":"none"}`)
  163. newCfg := makeHotConfig()
  164. // b expired and is stripped from the generated config (#5712); a's id rotated.
  165. newCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[{"email":"a","id":"uuid-a2"},{"email":"c","id":"uuid-c"}],"decryption":"none"}`)
  166. diff, ok := ComputeHotDiff(oldCfg, newCfg)
  167. if !ok {
  168. t.Fatal("client-only change must be hot-appliable")
  169. }
  170. if len(diff.RemovedInboundTags) != 0 || len(diff.AddedInbounds) != 0 {
  171. t.Fatalf("client-only change must not replace the handler, got %+v", diff)
  172. }
  173. removed := map[string]bool{}
  174. for _, u := range diff.RemovedUsers {
  175. if u.Tag != "inbound-1080" || u.Protocol != "vless" {
  176. t.Fatalf("removed user op has wrong target: %+v", u)
  177. }
  178. removed[u.Email] = true
  179. }
  180. if len(removed) != 2 || !removed["a"] || !removed["b"] {
  181. t.Fatalf("expected users a (changed) and b (gone) removed, got %v", removed)
  182. }
  183. added := map[string]string{}
  184. for _, u := range diff.AddedUsers {
  185. id, _ := u.User["id"].(string)
  186. added[u.Email] = id
  187. }
  188. if len(added) != 2 || added["a"] != "uuid-a2" || added["c"] != "uuid-c" {
  189. t.Fatalf("expected users a (new id) and c added, got %v", added)
  190. }
  191. }
  192. func TestComputeHotDiff_ClientChangeFallsBackToReplace(t *testing.T) {
  193. cases := []struct {
  194. name string
  195. mutate func(cfg *Config)
  196. }{
  197. {
  198. name: "unsupported protocol",
  199. mutate: func(cfg *Config) {
  200. cfg.InboundConfigs[1].Protocol = "shadowsocks"
  201. },
  202. },
  203. {
  204. name: "client without email",
  205. mutate: func(cfg *Config) {
  206. cfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[{"id":"uuid-a"}]}`)
  207. },
  208. },
  209. }
  210. for _, tc := range cases {
  211. t.Run(tc.name, func(t *testing.T) {
  212. oldCfg := makeHotConfig()
  213. newCfg := makeHotConfig()
  214. tc.mutate(oldCfg)
  215. tc.mutate(newCfg)
  216. newCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[{"email":"x","id":"uuid-x","password":"pw"}]}`)
  217. diff, ok := ComputeHotDiff(oldCfg, newCfg)
  218. if !ok {
  219. t.Fatal("change must still be hot-appliable via handler replacement")
  220. }
  221. if len(diff.RemovedUsers) != 0 || len(diff.AddedUsers) != 0 {
  222. t.Fatalf("expected no user ops, got %+v", diff)
  223. }
  224. if len(diff.RemovedInboundTags) != 1 || len(diff.AddedInbounds) != 1 {
  225. t.Fatalf("expected handler replacement, got %+v", diff)
  226. }
  227. })
  228. }
  229. }
  230. func TestComputeHotDiff_ApiInboundChangeNeedsRestart(t *testing.T) {
  231. newCfg := makeHotConfig()
  232. newCfg.InboundConfigs[0].Port = 62790
  233. if _, ok := ComputeHotDiff(makeHotConfig(), newCfg); ok {
  234. t.Fatal("api inbound change must force a restart")
  235. }
  236. }
  237. func TestComputeHotDiff_OutboundChangeAndReorder(t *testing.T) {
  238. oldCfg := makeHotConfig()
  239. newCfg := makeHotConfig()
  240. // change a non-first outbound + add one
  241. newCfg.OutboundConfigs = json_util.RawMessage(`[{"protocol":"freedom","tag":"direct"},{"protocol":"blackhole","settings":{},"tag":"blocked"},{"protocol":"socks","tag":"warp"}]`)
  242. diff, ok := ComputeHotDiff(oldCfg, newCfg)
  243. if !ok {
  244. t.Fatal("outbound-only change must be hot-appliable")
  245. }
  246. if len(diff.RemovedOutboundTags) != 1 || diff.RemovedOutboundTags[0] != "blocked" {
  247. t.Fatalf("expected changed outbound to be removed, got %v", diff.RemovedOutboundTags)
  248. }
  249. if len(diff.AddedOutbounds) != 2 {
  250. t.Fatalf("expected re-add + new add, got %d", len(diff.AddedOutbounds))
  251. }
  252. for _, raw := range diff.AddedOutbounds {
  253. if !strings.Contains(string(raw), `"tag"`) {
  254. t.Fatalf("added outbound JSON must be the raw element, got %s", raw)
  255. }
  256. }
  257. // pure reorder of non-first outbounds must be a no-op
  258. reordered := makeHotConfig()
  259. reordered.OutboundConfigs = json_util.RawMessage(`[{"protocol":"freedom","tag":"direct"},{"protocol":"socks","tag":"warp"},{"protocol":"blackhole","tag":"blocked"}]`)
  260. base := makeHotConfig()
  261. base.OutboundConfigs = json_util.RawMessage(`[{"protocol":"freedom","tag":"direct"},{"protocol":"blackhole","tag":"blocked"},{"protocol":"socks","tag":"warp"}]`)
  262. diff, ok = ComputeHotDiff(base, reordered)
  263. if !ok || !diff.Empty() {
  264. t.Fatalf("reorder of non-first outbounds must be an empty hot diff, ok=%v diff=%+v", ok, diff)
  265. }
  266. }
  267. func TestComputeHotDiff_FirstOutboundChangeNeedsRestart(t *testing.T) {
  268. newCfg := makeHotConfig()
  269. // change the default (first) outbound content
  270. newCfg.OutboundConfigs = json_util.RawMessage(`[{"protocol":"freedom","settings":{"domainStrategy":"UseIP"},"tag":"direct"},{"protocol":"blackhole","tag":"blocked"}]`)
  271. if _, ok := ComputeHotDiff(makeHotConfig(), newCfg); ok {
  272. t.Fatal("changing the default outbound must force a restart")
  273. }
  274. // swap which outbound comes first
  275. newCfg = makeHotConfig()
  276. newCfg.OutboundConfigs = json_util.RawMessage(`[{"protocol":"blackhole","tag":"blocked"},{"protocol":"freedom","tag":"direct"}]`)
  277. if _, ok := ComputeHotDiff(makeHotConfig(), newCfg); ok {
  278. t.Fatal("changing the first outbound must force a restart")
  279. }
  280. }
  281. func TestComputeHotDiff_TaglessOutboundNeedsRestart(t *testing.T) {
  282. newCfg := makeHotConfig()
  283. newCfg.OutboundConfigs = json_util.RawMessage(`[{"protocol":"freedom","tag":"direct"},{"protocol":"blackhole"}]`)
  284. if _, ok := ComputeHotDiff(makeHotConfig(), newCfg); ok {
  285. t.Fatal("tagless outbound must force a restart")
  286. }
  287. }
  288. func TestComputeHotDiff_RoutingRulesChange(t *testing.T) {
  289. newCfg := makeHotConfig()
  290. newCfg.RouterConfig = json_util.RawMessage(`{"domainStrategy":"AsIs","rules":[{"type":"field","inboundTag":["api"],"outboundTag":"api"},{"type":"field","ip":["geoip:private"],"outboundTag":"blocked"}]}`)
  291. diff, ok := ComputeHotDiff(makeHotConfig(), newCfg)
  292. if !ok {
  293. t.Fatal("rules-only routing change must be hot-appliable")
  294. }
  295. if diff.RoutingConfig == nil {
  296. t.Fatal("routing diff must carry the new routing section")
  297. }
  298. // balancers are reloadable too
  299. newCfg = makeHotConfig()
  300. newCfg.RouterConfig = json_util.RawMessage(`{"domainStrategy":"AsIs","rules":[],"balancers":[{"tag":"b1","selector":["wg"]}]}`)
  301. if _, ok := ComputeHotDiff(makeHotConfig(), newCfg); !ok {
  302. t.Fatal("balancer-only routing change must be hot-appliable")
  303. }
  304. }
  305. func TestComputeHotDiff_RoutingStrategyChangeNeedsRestart(t *testing.T) {
  306. newCfg := makeHotConfig()
  307. newCfg.RouterConfig = json_util.RawMessage(`{"domainStrategy":"IPIfNonMatch","rules":[{"type":"field","inboundTag":["api"],"outboundTag":"api"}]}`)
  308. if _, ok := ComputeHotDiff(makeHotConfig(), newCfg); ok {
  309. t.Fatal("domainStrategy change must force a restart")
  310. }
  311. }