hot_diff_test.go 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602
  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. "github.com/xtls/xray-core/proxy/shadowsocks"
  10. )
  11. func TestMain(m *testing.M) {
  12. // ComputeHotDiff logs the section that blocks a hot apply; the package
  13. // logger must exist before any test exercises a blocked path.
  14. xuilogger.InitLogger(logging.ERROR)
  15. os.Exit(m.Run())
  16. }
  17. func makeHotConfig() *Config {
  18. return &Config{
  19. LogConfig: json_util.RawMessage(`{"loglevel":"warning"}`),
  20. RouterConfig: json_util.RawMessage(`{"domainStrategy":"AsIs","rules":[{"type":"field","inboundTag":["api"],"outboundTag":"api"}]}`),
  21. OutboundConfigs: json_util.RawMessage(`[{"protocol":"freedom","tag":"direct"},{"protocol":"blackhole","tag":"blocked"}]`),
  22. Policy: json_util.RawMessage(`{}`),
  23. API: json_util.RawMessage(`{"services":["HandlerService","StatsService","RoutingService"],"tag":"api"}`),
  24. Stats: json_util.RawMessage(`{}`),
  25. Metrics: json_util.RawMessage(`{}`),
  26. InboundConfigs: []InboundConfig{
  27. {
  28. Port: 62789,
  29. Protocol: "tunnel",
  30. Tag: "api",
  31. Listen: json_util.RawMessage(`"127.0.0.1"`),
  32. Settings: json_util.RawMessage(`{}`),
  33. },
  34. {
  35. Port: 1080,
  36. Protocol: "vless",
  37. Tag: "inbound-1080",
  38. Listen: json_util.RawMessage(`"0.0.0.0"`),
  39. Settings: json_util.RawMessage(`{"clients":[]}`),
  40. },
  41. },
  42. }
  43. }
  44. func TestComputeHotDiff_NoChanges(t *testing.T) {
  45. diff, ok := ComputeHotDiff(makeHotConfig(), makeHotConfig())
  46. if !ok {
  47. t.Fatal("identical configs must be hot-appliable")
  48. }
  49. if !diff.Empty() {
  50. t.Fatalf("identical configs must produce an empty diff, got %+v", diff)
  51. }
  52. }
  53. func TestComputeHotDiff_FormattingOnlyChangeIsEmptyDiff(t *testing.T) {
  54. oldCfg := makeHotConfig()
  55. newCfg := makeHotConfig()
  56. // Reformat every section the way a frontend textarea save would.
  57. newCfg.LogConfig = json_util.RawMessage("{\n \"loglevel\": \"warning\"\n}")
  58. newCfg.Policy = json_util.RawMessage("{ }")
  59. newCfg.API = json_util.RawMessage("{\n \"services\": [\"HandlerService\", \"StatsService\", \"RoutingService\"],\n \"tag\": \"api\"\n}")
  60. newCfg.OutboundConfigs = json_util.RawMessage("[\n {\"protocol\": \"freedom\", \"tag\": \"direct\"},\n {\"protocol\": \"blackhole\", \"tag\": \"blocked\"}\n]")
  61. newCfg.InboundConfigs[1].Settings = json_util.RawMessage("{\n \"clients\": []\n}")
  62. diff, ok := ComputeHotDiff(oldCfg, newCfg)
  63. if !ok {
  64. t.Fatal("formatting-only change must be hot-appliable")
  65. }
  66. if len(diff.RemovedInboundTags) != 0 || len(diff.AddedInbounds) != 0 ||
  67. len(diff.RemovedOutboundTags) != 0 || len(diff.AddedOutbounds) != 0 {
  68. t.Fatalf("formatting-only change must produce no handler ops, got %+v", diff)
  69. }
  70. }
  71. func TestComputeHotDiff_CanonicalEquality(t *testing.T) {
  72. // Key reorder in a static section (the DNS editor rebuilds the object on
  73. // save) must not read as a change.
  74. oldCfg := makeHotConfig()
  75. oldCfg.DNSConfig = json_util.RawMessage(`{"servers":["1.1.1.1"],"queryStrategy":"UseIP","tag":"dns-in"}`)
  76. newCfg := makeHotConfig()
  77. newCfg.DNSConfig = json_util.RawMessage(`{"tag":"dns-in","queryStrategy":"UseIP","servers":["1.1.1.1"]}`)
  78. diff, ok := ComputeHotDiff(oldCfg, newCfg)
  79. if !ok || !diff.Empty() {
  80. t.Fatalf("dns key reorder must be an empty hot diff, ok=%v diff=%+v", ok, diff)
  81. }
  82. // Explicit null and an absent section are the same thing.
  83. newCfg = makeHotConfig()
  84. newCfg.FakeDNS = json_util.RawMessage(`null`)
  85. diff, ok = ComputeHotDiff(makeHotConfig(), newCfg)
  86. if !ok || !diff.Empty() {
  87. t.Fatalf("fakedns null vs absent must be an empty hot diff, ok=%v diff=%+v", ok, diff)
  88. }
  89. // A real DNS change still forces a restart — there is no reload API.
  90. newCfg = makeHotConfig()
  91. newCfg.DNSConfig = json_util.RawMessage(`{"servers":["8.8.8.8"]}`)
  92. if _, ok := ComputeHotDiff(makeHotConfig(), newCfg); ok {
  93. t.Fatal("real dns change must force a restart")
  94. }
  95. // Large integers keep full precision during normalization: two values
  96. // that only differ past float64 precision must still read as a change.
  97. oldCfg = makeHotConfig()
  98. oldCfg.Policy = json_util.RawMessage(`{"big":9007199254740993}`)
  99. newCfg = makeHotConfig()
  100. newCfg.Policy = json_util.RawMessage(`{"big":9007199254740992}`)
  101. if _, ok := ComputeHotDiff(oldCfg, newCfg); ok {
  102. t.Fatal("values differing past float64 precision must not compare equal")
  103. }
  104. // Reordered keys inside the first (default) outbound must not force a
  105. // restart — the form editor rebuilds the object on save.
  106. oldCfg = makeHotConfig()
  107. oldCfg.OutboundConfigs = json_util.RawMessage(`[{"protocol":"freedom","settings":{"domainStrategy":"AsIs"},"tag":"direct"},{"protocol":"blackhole","tag":"blocked"}]`)
  108. newCfg = makeHotConfig()
  109. newCfg.OutboundConfigs = json_util.RawMessage(`[{"tag":"direct","settings":{"domainStrategy":"AsIs"},"protocol":"freedom"},{"protocol":"blackhole","tag":"blocked"}]`)
  110. diff, ok = ComputeHotDiff(oldCfg, newCfg)
  111. if !ok || !diff.Empty() {
  112. t.Fatalf("first outbound key reorder must be an empty hot diff, ok=%v diff=%+v", ok, diff)
  113. }
  114. }
  115. func TestComputeHotDiff_StaticSectionChangeNeedsRestart(t *testing.T) {
  116. newCfg := makeHotConfig()
  117. newCfg.LogConfig = json_util.RawMessage(`{"loglevel":"debug"}`)
  118. if _, ok := ComputeHotDiff(makeHotConfig(), newCfg); ok {
  119. t.Fatal("log change must force a restart")
  120. }
  121. newCfg = makeHotConfig()
  122. newCfg.DNSConfig = json_util.RawMessage(`{"servers":["1.1.1.1"]}`)
  123. if _, ok := ComputeHotDiff(makeHotConfig(), newCfg); ok {
  124. t.Fatal("dns change must force a restart")
  125. }
  126. newCfg = makeHotConfig()
  127. newCfg.Observatory = json_util.RawMessage(`{"subjectSelector":["wg"]}`)
  128. if _, ok := ComputeHotDiff(makeHotConfig(), newCfg); ok {
  129. t.Fatal("observatory change must force a restart")
  130. }
  131. newCfg = makeHotConfig()
  132. newCfg.Env = json_util.RawMessage(`{"XRAY_DNS_PATH":"/tmp/dns"}`)
  133. if _, ok := ComputeHotDiff(makeHotConfig(), newCfg); ok {
  134. t.Fatal("env change must force a restart: env vars are read only at process start")
  135. }
  136. }
  137. func TestComputeHotDiff_InboundAddRemoveChange(t *testing.T) {
  138. oldCfg := makeHotConfig()
  139. newCfg := makeHotConfig()
  140. // change existing beyond the clients list, so no user-level shortcut applies
  141. newCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[],"decryption":"none"}`)
  142. // add new
  143. newCfg.InboundConfigs = append(newCfg.InboundConfigs, InboundConfig{
  144. Port: 2080, Protocol: "vmess", Tag: "inbound-2080",
  145. Settings: json_util.RawMessage(`{}`),
  146. })
  147. diff, ok := ComputeHotDiff(oldCfg, newCfg)
  148. if !ok {
  149. t.Fatal("inbound-only change must be hot-appliable")
  150. }
  151. if len(diff.RemovedInboundTags) != 1 || diff.RemovedInboundTags[0] != "inbound-1080" {
  152. t.Fatalf("expected changed inbound to be removed, got %v", diff.RemovedInboundTags)
  153. }
  154. if len(diff.AddedInbounds) != 2 {
  155. t.Fatalf("expected re-add + new add, got %d", len(diff.AddedInbounds))
  156. }
  157. if diff.RoutingConfig != nil || len(diff.AddedOutbounds) != 0 || len(diff.RemovedOutboundTags) != 0 {
  158. t.Fatalf("unexpected non-inbound operations: %+v", diff)
  159. }
  160. }
  161. func TestComputeHotDiff_ClientOnlyChangeUsesUserOps(t *testing.T) {
  162. oldCfg := makeHotConfig()
  163. oldCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[{"email":"a","id":"uuid-a"},{"email":"b","id":"uuid-b"}],"decryption":"none"}`)
  164. newCfg := makeHotConfig()
  165. // b expired and is stripped from the generated config (#5712); a's id rotated.
  166. newCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[{"email":"a","id":"uuid-a2"},{"email":"c","id":"uuid-c"}],"decryption":"none"}`)
  167. diff, ok := ComputeHotDiff(oldCfg, newCfg)
  168. if !ok {
  169. t.Fatal("client-only change must be hot-appliable")
  170. }
  171. if len(diff.RemovedInboundTags) != 0 || len(diff.AddedInbounds) != 0 {
  172. t.Fatalf("client-only change must not replace the handler, got %+v", diff)
  173. }
  174. removed := map[string]bool{}
  175. for _, u := range diff.RemovedUsers {
  176. if u.Tag != "inbound-1080" || u.Protocol != "vless" {
  177. t.Fatalf("removed user op has wrong target: %+v", u)
  178. }
  179. removed[u.Email] = true
  180. }
  181. if len(removed) != 2 || !removed["a"] || !removed["b"] {
  182. t.Fatalf("expected users a (changed) and b (gone) removed, got %v", removed)
  183. }
  184. added := map[string]string{}
  185. for _, u := range diff.AddedUsers {
  186. id, _ := u.User["id"].(string)
  187. added[u.Email] = id
  188. }
  189. if len(added) != 2 || added["a"] != "uuid-a2" || added["c"] != "uuid-c" {
  190. t.Fatalf("expected users a (new id) and c added, got %v", added)
  191. }
  192. }
  193. // A MASQUE inbound carries every user's CONNECT-IP tunnel; replacing the handler to
  194. // add one user would drop all of them, so its client list diffs per user too.
  195. func TestComputeHotDiff_MasqueClientAddUsesUserOps(t *testing.T) {
  196. oldCfg := makeHotConfig()
  197. oldCfg.InboundConfigs[1].Protocol = "masque"
  198. oldCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[{"email":"a","pass":"pw-a"}],"address":["10.14.0.1/24"]}`)
  199. newCfg := makeHotConfig()
  200. newCfg.InboundConfigs[1].Protocol = "masque"
  201. newCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[{"email":"a","pass":"pw-a"},{"email":"b","pass":"pw-b"}],"address":["10.14.0.1/24"]}`)
  202. diff, ok := ComputeHotDiff(oldCfg, newCfg)
  203. if !ok {
  204. t.Fatal("a MASQUE client-only change must be hot-appliable")
  205. }
  206. if len(diff.RemovedInboundTags) != 0 || len(diff.AddedInbounds) != 0 {
  207. t.Fatalf("a MASQUE client add must not replace the handler, got %+v", diff)
  208. }
  209. if len(diff.RemovedUsers) != 0 || len(diff.AddedUsers) != 1 ||
  210. diff.AddedUsers[0].Email != "b" || diff.AddedUsers[0].User["pass"] != "pw-b" {
  211. t.Fatalf("expected only user b added with its pass, got added %+v removed %+v", diff.AddedUsers, diff.RemovedUsers)
  212. }
  213. }
  214. func TestComputeHotDiff_ClientChangeFallsBackToReplace(t *testing.T) {
  215. cases := []struct {
  216. name string
  217. mutate func(cfg *Config)
  218. }{
  219. {
  220. name: "unsupported protocol",
  221. mutate: func(cfg *Config) {
  222. cfg.InboundConfigs[1].Protocol = "http"
  223. },
  224. },
  225. {
  226. name: "client without email",
  227. mutate: func(cfg *Config) {
  228. cfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[{"id":"uuid-a"}]}`)
  229. },
  230. },
  231. }
  232. for _, tc := range cases {
  233. t.Run(tc.name, func(t *testing.T) {
  234. oldCfg := makeHotConfig()
  235. newCfg := makeHotConfig()
  236. tc.mutate(oldCfg)
  237. tc.mutate(newCfg)
  238. newCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[{"email":"x","id":"uuid-x","password":"pw"}]}`)
  239. diff, ok := ComputeHotDiff(oldCfg, newCfg)
  240. if !ok {
  241. t.Fatal("change must still be hot-appliable via handler replacement")
  242. }
  243. if len(diff.RemovedUsers) != 0 || len(diff.AddedUsers) != 0 {
  244. t.Fatalf("expected no user ops, got %+v", diff)
  245. }
  246. if len(diff.RemovedInboundTags) != 1 || len(diff.AddedInbounds) != 1 {
  247. t.Fatalf("expected handler replacement, got %+v", diff)
  248. }
  249. })
  250. }
  251. }
  252. func TestComputeHotDiff_ApiInboundChangeNeedsRestart(t *testing.T) {
  253. newCfg := makeHotConfig()
  254. newCfg.InboundConfigs[0].Port = 62790
  255. if _, ok := ComputeHotDiff(makeHotConfig(), newCfg); ok {
  256. t.Fatal("api inbound change must force a restart")
  257. }
  258. }
  259. func TestComputeHotDiff_OutboundChangeAndReorder(t *testing.T) {
  260. oldCfg := makeHotConfig()
  261. newCfg := makeHotConfig()
  262. // change a non-first outbound + add one
  263. newCfg.OutboundConfigs = json_util.RawMessage(`[{"protocol":"freedom","tag":"direct"},{"protocol":"blackhole","settings":{},"tag":"blocked"},{"protocol":"socks","tag":"warp"}]`)
  264. diff, ok := ComputeHotDiff(oldCfg, newCfg)
  265. if !ok {
  266. t.Fatal("outbound-only change must be hot-appliable")
  267. }
  268. if len(diff.RemovedOutboundTags) != 1 || diff.RemovedOutboundTags[0] != "blocked" {
  269. t.Fatalf("expected changed outbound to be removed, got %v", diff.RemovedOutboundTags)
  270. }
  271. if len(diff.AddedOutbounds) != 2 {
  272. t.Fatalf("expected re-add + new add, got %d", len(diff.AddedOutbounds))
  273. }
  274. for _, raw := range diff.AddedOutbounds {
  275. if !strings.Contains(string(raw), `"tag"`) {
  276. t.Fatalf("added outbound JSON must be the raw element, got %s", raw)
  277. }
  278. }
  279. // pure reorder of non-first outbounds must be a no-op
  280. reordered := makeHotConfig()
  281. reordered.OutboundConfigs = json_util.RawMessage(`[{"protocol":"freedom","tag":"direct"},{"protocol":"socks","tag":"warp"},{"protocol":"blackhole","tag":"blocked"}]`)
  282. base := makeHotConfig()
  283. base.OutboundConfigs = json_util.RawMessage(`[{"protocol":"freedom","tag":"direct"},{"protocol":"blackhole","tag":"blocked"},{"protocol":"socks","tag":"warp"}]`)
  284. diff, ok = ComputeHotDiff(base, reordered)
  285. if !ok || !diff.Empty() {
  286. t.Fatalf("reorder of non-first outbounds must be an empty hot diff, ok=%v diff=%+v", ok, diff)
  287. }
  288. }
  289. func TestComputeHotDiff_FirstOutboundChangeNeedsRestart(t *testing.T) {
  290. newCfg := makeHotConfig()
  291. // change the default (first) outbound content
  292. newCfg.OutboundConfigs = json_util.RawMessage(`[{"protocol":"freedom","settings":{"domainStrategy":"UseIP"},"tag":"direct"},{"protocol":"blackhole","tag":"blocked"}]`)
  293. if _, ok := ComputeHotDiff(makeHotConfig(), newCfg); ok {
  294. t.Fatal("changing the default outbound must force a restart")
  295. }
  296. // swap which outbound comes first
  297. newCfg = makeHotConfig()
  298. newCfg.OutboundConfigs = json_util.RawMessage(`[{"protocol":"blackhole","tag":"blocked"},{"protocol":"freedom","tag":"direct"}]`)
  299. if _, ok := ComputeHotDiff(makeHotConfig(), newCfg); ok {
  300. t.Fatal("changing the first outbound must force a restart")
  301. }
  302. }
  303. func TestComputeHotDiff_TaglessOutboundNeedsRestart(t *testing.T) {
  304. newCfg := makeHotConfig()
  305. newCfg.OutboundConfigs = json_util.RawMessage(`[{"protocol":"freedom","tag":"direct"},{"protocol":"blackhole"}]`)
  306. if _, ok := ComputeHotDiff(makeHotConfig(), newCfg); ok {
  307. t.Fatal("tagless outbound must force a restart")
  308. }
  309. }
  310. func TestComputeHotDiff_RoutingRulesChange(t *testing.T) {
  311. newCfg := makeHotConfig()
  312. newCfg.RouterConfig = json_util.RawMessage(`{"domainStrategy":"AsIs","rules":[{"type":"field","inboundTag":["api"],"outboundTag":"api"},{"type":"field","ip":["geoip:private"],"outboundTag":"blocked"}]}`)
  313. diff, ok := ComputeHotDiff(makeHotConfig(), newCfg)
  314. if !ok {
  315. t.Fatal("rules-only routing change must be hot-appliable")
  316. }
  317. if diff.RoutingConfig == nil {
  318. t.Fatal("routing diff must carry the new routing section")
  319. }
  320. // balancers are reloadable too
  321. newCfg = makeHotConfig()
  322. newCfg.RouterConfig = json_util.RawMessage(`{"domainStrategy":"AsIs","rules":[],"balancers":[{"tag":"b1","selector":["wg"]}]}`)
  323. if _, ok := ComputeHotDiff(makeHotConfig(), newCfg); !ok {
  324. t.Fatal("balancer-only routing change must be hot-appliable")
  325. }
  326. }
  327. func TestComputeHotDiff_RoutingStrategyChangeNeedsRestart(t *testing.T) {
  328. newCfg := makeHotConfig()
  329. newCfg.RouterConfig = json_util.RawMessage(`{"domainStrategy":"IPIfNonMatch","rules":[{"type":"field","inboundTag":["api"],"outboundTag":"api"}]}`)
  330. if _, ok := ComputeHotDiff(makeHotConfig(), newCfg); ok {
  331. t.Fatal("domainStrategy change must force a restart")
  332. }
  333. }
  334. func TestComputeHotDiff_RealityStreamChangeNeedsRestart(t *testing.T) {
  335. oldCfg := makeHotConfig()
  336. oldCfg.InboundConfigs[1].StreamSettings = json_util.RawMessage(`{"network":"tcp","security":"reality","realitySettings":{"privateKey":"old-key","serverNames":["a.example"]}}`)
  337. newCfg := makeHotConfig()
  338. newCfg.InboundConfigs[1].StreamSettings = json_util.RawMessage(`{"network":"tcp","security":"reality","realitySettings":{"privateKey":"new-key","serverNames":["a.example"]}}`)
  339. if _, ok := ComputeHotDiff(oldCfg, newCfg); ok {
  340. t.Fatal("a REALITY stream-settings change must force a full restart, not a gRPC hot swap")
  341. }
  342. }
  343. func TestComputeHotDiff_SecuritySwitchToRealityNeedsRestart(t *testing.T) {
  344. oldCfg := makeHotConfig()
  345. oldCfg.InboundConfigs[1].StreamSettings = json_util.RawMessage(`{"network":"tcp","security":"none"}`)
  346. newCfg := makeHotConfig()
  347. newCfg.InboundConfigs[1].StreamSettings = json_util.RawMessage(`{"network":"tcp","security":"reality","realitySettings":{"privateKey":"k"}}`)
  348. if _, ok := ComputeHotDiff(oldCfg, newCfg); ok {
  349. t.Fatal("switching security to REALITY must force a full restart")
  350. }
  351. }
  352. func TestComputeHotDiff_RealityClientOnlyChangeStaysHot(t *testing.T) {
  353. oldCfg := makeHotConfig()
  354. oldCfg.InboundConfigs[1].StreamSettings = json_util.RawMessage(`{"network":"tcp","security":"reality","realitySettings":{"privateKey":"k"}}`)
  355. oldCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[{"email":"a","id":"uuid-a"}],"decryption":"none"}`)
  356. newCfg := makeHotConfig()
  357. newCfg.InboundConfigs[1].StreamSettings = json_util.RawMessage(`{"network":"tcp","security":"reality","realitySettings":{"privateKey":"k"}}`)
  358. newCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[{"email":"a","id":"uuid-a"},{"email":"b","id":"uuid-b"}],"decryption":"none"}`)
  359. diff, ok := ComputeHotDiff(oldCfg, newCfg)
  360. if !ok {
  361. t.Fatal("client-only change on a REALITY inbound must stay hot-appliable")
  362. }
  363. if len(diff.RemovedInboundTags) != 0 || len(diff.AddedInbounds) != 0 {
  364. t.Fatalf("client-only change must not replace the handler, got %+v", diff)
  365. }
  366. if len(diff.AddedUsers) != 1 || diff.AddedUsers[0].Email != "b" {
  367. t.Fatalf("expected user b added via AlterInbound, got %+v", diff.AddedUsers)
  368. }
  369. }
  370. // TestComputeHotDiff_NewTproxyInboundNeedsRestart reproduces a real incident:
  371. // enabling RouteThroughXray on an AmneziaWG inbound while Xray is already
  372. // running adds a brand-new dokodemo-door bridge with sockopt.tproxy set.
  373. // Xray-core's gRPC AddInbound reports success for this but never actually
  374. // binds a working listener, so TPROXY-redirected peer traffic silently goes
  375. // nowhere until the next full restart -- confirmed directly on a real box
  376. // (iptables TPROXY counters incrementing, but `ss` showing nothing listening
  377. // on the bridge port; the listener only appeared after `systemctl restart
  378. // x-ui`). This must force a restart instead of a hot add.
  379. func TestComputeHotDiff_NewTproxyInboundNeedsRestart(t *testing.T) {
  380. oldCfg := makeHotConfig()
  381. newCfg := makeHotConfig()
  382. newCfg.InboundConfigs = append(newCfg.InboundConfigs, InboundConfig{
  383. Listen: json_util.RawMessage(`"127.0.0.1"`),
  384. Port: 63110,
  385. Protocol: "dokodemo-door",
  386. Tag: "in-443-udp",
  387. Settings: json_util.RawMessage(`{"allowedNetwork":"tcp,udp","followRedirect":true}`),
  388. StreamSettings: json_util.RawMessage(`{"sockopt":{"tproxy":"tproxy"}}`),
  389. })
  390. if _, ok := ComputeHotDiff(oldCfg, newCfg); ok {
  391. t.Fatal("adding a new TPROXY-sockopt inbound must force a full restart, not a gRPC hot add")
  392. }
  393. }
  394. // TestComputeHotDiff_TproxyStreamChangeNeedsRestart mirrors the REALITY
  395. // stream-change test above: an existing TPROXY bridge whose port changed
  396. // (e.g. the AmneziaWG inbound's own id-derived egress port shifted) must not
  397. // be hot-swapped either, for the same reliability reason.
  398. func TestComputeHotDiff_TproxyStreamChangeNeedsRestart(t *testing.T) {
  399. tproxyIb := InboundConfig{
  400. Listen: json_util.RawMessage(`"127.0.0.1"`),
  401. Port: 63110,
  402. Protocol: "dokodemo-door",
  403. Tag: "in-443-udp",
  404. Settings: json_util.RawMessage(`{"allowedNetwork":"tcp,udp","followRedirect":true}`),
  405. StreamSettings: json_util.RawMessage(`{"sockopt":{"tproxy":"tproxy"}}`),
  406. }
  407. oldCfg := makeHotConfig()
  408. oldCfg.InboundConfigs = append(oldCfg.InboundConfigs, tproxyIb)
  409. newCfg := makeHotConfig()
  410. changedIb := tproxyIb
  411. changedIb.Port = 63111
  412. newCfg.InboundConfigs = append(newCfg.InboundConfigs, changedIb)
  413. if _, ok := ComputeHotDiff(oldCfg, newCfg); ok {
  414. t.Fatal("a TPROXY bridge's port change must force a full restart, not a gRPC hot swap")
  415. }
  416. }
  417. // TestComputeHotDiff_SocksAccountsSettingsChangeNeedsRestart reproduces a
  418. // real incident: editing one client under an AmneziaWG inbound left its
  419. // relay's settings.json byte-different (a new account list) while Xray was
  420. // already running. A gRPC remove+add reported success but silently dropped
  421. // an account with a non-ASCII email; a full restart always produced the
  422. // correct account list. This must force a restart, not a hot swap.
  423. func TestComputeHotDiff_SocksAccountsSettingsChangeNeedsRestart(t *testing.T) {
  424. relayIb := InboundConfig{
  425. Listen: json_util.RawMessage(`"127.0.0.1"`),
  426. Port: 65110,
  427. Protocol: "socks",
  428. Tag: "in-443-udp",
  429. Settings: json_util.RawMessage(`{"auth":"password","udp":true,"accounts":[{"user":"Роутер_awg","pass":"p"}]}`),
  430. }
  431. oldCfg := makeHotConfig()
  432. oldCfg.InboundConfigs = append(oldCfg.InboundConfigs, relayIb)
  433. newCfg := makeHotConfig()
  434. changedIb := relayIb
  435. changedIb.Settings = json_util.RawMessage(`{"auth":"password","udp":true,"accounts":[{"user":"Роутер_awg","pass":"p"},{"user":"Майфун🛟","pass":"p"}]}`)
  436. newCfg.InboundConfigs = append(newCfg.InboundConfigs, changedIb)
  437. if _, ok := ComputeHotDiff(oldCfg, newCfg); ok {
  438. t.Fatal("a password-auth SOCKS5 relay's account-list change must force a full restart, not a gRPC hot swap")
  439. }
  440. }
  441. // TestComputeHotDiff_NewSocksAccountsInboundNeedsRestart mirrors the TPROXY
  442. // new-inbound test above: a brand-new AmneziaWG relay inbound must also
  443. // force a restart, for the same reliability reason.
  444. func TestComputeHotDiff_NewSocksAccountsInboundNeedsRestart(t *testing.T) {
  445. oldCfg := makeHotConfig()
  446. newCfg := makeHotConfig()
  447. newCfg.InboundConfigs = append(newCfg.InboundConfigs, InboundConfig{
  448. Listen: json_util.RawMessage(`"127.0.0.1"`),
  449. Port: 65110,
  450. Protocol: "socks",
  451. Tag: "in-443-udp",
  452. Settings: json_util.RawMessage(`{"auth":"password","udp":true,"accounts":[{"user":"Майфун🛟","pass":"p"}]}`),
  453. })
  454. if _, ok := ComputeHotDiff(oldCfg, newCfg); ok {
  455. t.Fatal("adding a new password-auth SOCKS5 relay inbound must force a full restart, not a gRPC hot add")
  456. }
  457. }
  458. // TestComputeHotDiff_NoauthSocksBridgeStaysHot confirms the check above is
  459. // scoped to password-auth accounts specifically: this fork's other SOCKS5
  460. // bridges (panel egress, per-node egress, mtproto egress) use "noauth" with
  461. // no per-account identity, have no history of this failure mode, and must
  462. // keep using the ordinary remove+add hot path rather than pay for an
  463. // unnecessary restart on every port/tag change.
  464. func TestComputeHotDiff_NoauthSocksBridgeStaysHot(t *testing.T) {
  465. bridgeIb := InboundConfig{
  466. Listen: json_util.RawMessage(`"127.0.0.1"`),
  467. Port: 62790,
  468. Protocol: "socks",
  469. Tag: "panel-egress",
  470. Settings: json_util.RawMessage(`{"auth":"noauth","udp":false}`),
  471. }
  472. oldCfg := makeHotConfig()
  473. oldCfg.InboundConfigs = append(oldCfg.InboundConfigs, bridgeIb)
  474. newCfg := makeHotConfig()
  475. changedIb := bridgeIb
  476. changedIb.Port = 62791
  477. newCfg.InboundConfigs = append(newCfg.InboundConfigs, changedIb)
  478. diff, ok := ComputeHotDiff(oldCfg, newCfg)
  479. if !ok {
  480. t.Fatal("a noauth SOCKS5 bridge's port change should stay hot-appliable")
  481. }
  482. if len(diff.RemovedInboundTags) != 1 || len(diff.AddedInbounds) != 1 {
  483. t.Fatalf("expected a plain remove+add for the changed bridge, got %+v", diff)
  484. }
  485. }
  486. // Replacing a hysteria handler closes the UDP listener its QUIC sessions share,
  487. // and clients get no reset: they stall until their own idle timeout expires.
  488. func TestComputeHotDiff_HysteriaClientOnlyChangeKeepsListener(t *testing.T) {
  489. stream := json_util.RawMessage(`{"network":"hysteria","security":"tls","hysteriaSettings":{"version":2}}`)
  490. oldCfg := makeHotConfig()
  491. oldCfg.InboundConfigs[1].Protocol = "hysteria"
  492. oldCfg.InboundConfigs[1].StreamSettings = stream
  493. oldCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"version":2,"clients":[{"email":"a","auth":"auth-a"}]}`)
  494. newCfg := makeHotConfig()
  495. newCfg.InboundConfigs[1].Protocol = "hysteria"
  496. newCfg.InboundConfigs[1].StreamSettings = stream
  497. newCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"version":2,"clients":[{"email":"a","auth":"auth-a"},{"email":"b","auth":"auth-b"}]}`)
  498. diff, ok := ComputeHotDiff(oldCfg, newCfg)
  499. if !ok {
  500. t.Fatal("client-only change must be hot-appliable")
  501. }
  502. if len(diff.RemovedInboundTags) != 0 || len(diff.AddedInbounds) != 0 {
  503. t.Fatalf("hysteria client-only change must not replace the handler, got removed=%v added=%d", diff.RemovedInboundTags, len(diff.AddedInbounds))
  504. }
  505. if len(diff.RemovedUsers) != 0 || len(diff.AddedUsers) != 1 || diff.AddedUsers[0].Email != "b" || diff.AddedUsers[0].Protocol != "hysteria" {
  506. t.Fatalf("expected a single AddUser op for b, got %+v", diff)
  507. }
  508. }
  509. // 2022 clients carry no method of their own, so a user op built from the client
  510. // object alone has no cipher and its account cannot be built for the core.
  511. func TestInboundUserOps_ShadowsocksAddedUserBuildsTheInboundAccount(t *testing.T) {
  512. cases := []struct {
  513. name, settingsHead, clientMethod string
  514. wantLegacy bool
  515. }{
  516. {"legacy aead", `"method":"aes-256-gcm"`, `"method":"aes-256-gcm",`, true},
  517. {"2022", `"method":"2022-blake3-aes-128-gcm","password":"AAECAwQFBgcICQoLDA0ODw=="`, "", false},
  518. }
  519. for _, tc := range cases {
  520. t.Run(tc.name, func(t *testing.T) {
  521. inbound := func(clients ...string) *InboundConfig {
  522. return &InboundConfig{
  523. Port: 8388, Protocol: "shadowsocks", Tag: "inbound-8388",
  524. Listen: json_util.RawMessage(`"0.0.0.0"`),
  525. Settings: json_util.RawMessage(`{` + tc.settingsHead + `,"network":"tcp,udp","clients":[` + strings.Join(clients, ",") + `]}`),
  526. }
  527. }
  528. clientA := `{` + tc.clientMethod + `"email":"a","password":"EBESExQVFhcYGRobHB0eHw=="}`
  529. clientB := `{` + tc.clientMethod + `"email":"b","password":"ICEiIyQlJicoKSorLC0uLw=="}`
  530. diff, ok := InboundUserOps(inbound(clientA), inbound(clientA, clientB))
  531. if !ok {
  532. t.Fatal("a client-only shadowsocks change must apply through per-user ops")
  533. }
  534. if len(diff.RemovedUsers) != 0 || len(diff.AddedUsers) != 1 || diff.AddedUsers[0].Email != "b" {
  535. t.Fatalf("expected only user b added, got added %+v removed %+v", diff.AddedUsers, diff.RemovedUsers)
  536. }
  537. _, legacy, modern := decodeSSAccount(t, diff.AddedUsers[0].User)
  538. if tc.wantLegacy && (legacy == nil || legacy.CipherType != shadowsocks.CipherType_AES_256_GCM) {
  539. t.Fatalf("want a legacy aes-256-gcm account, got legacy %v modern %v", legacy, modern)
  540. }
  541. if !tc.wantLegacy && (modern == nil || modern.Key != "ICEiIyQlJicoKSorLC0uLw==") {
  542. t.Fatalf("want a 2022 account keyed by b's password, got legacy %v modern %v", legacy, modern)
  543. }
  544. })
  545. }
  546. }