dns_outbound_legacy_keys_migration_test.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  1. package database
  2. import (
  3. "encoding/json"
  4. "strings"
  5. "testing"
  6. corelog "github.com/xtls/xray-core/common/log"
  7. "github.com/mhsanaei/3x-ui/v3/internal/config"
  8. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  9. "github.com/mhsanaei/3x-ui/v3/internal/xray"
  10. )
  11. func TestRewriteDNSOutboundLegacyKeys(t *testing.T) {
  12. tests := []struct {
  13. name string
  14. raw string
  15. wantChanged bool
  16. wantOutbound map[string]any
  17. }{
  18. {
  19. name: "reject keeps the blocked qTypes and answers rCode 5",
  20. raw: `{"outbounds":[{"protocol":"dns","tag":"dns-out","settings":{"rewriteNetwork":"udp","rewriteAddress":"8.8.8.8","rewritePort":53,"nonIPQuery":"reject","blockTypes":[65,28]}}]}`,
  21. wantChanged: true,
  22. wantOutbound: map[string]any{
  23. "protocol": "dns", "tag": "dns-out",
  24. "settings": map[string]any{
  25. "rewriteNetwork": "udp", "rewriteAddress": "8.8.8.8", "rewritePort": float64(53),
  26. "rules": []any{
  27. map[string]any{"action": "return", "qType": "65,28", "rCode": float64(5)},
  28. map[string]any{"action": "hijack", "qType": "1,28"},
  29. map[string]any{"action": "return", "rCode": float64(5)},
  30. },
  31. },
  32. },
  33. },
  34. {
  35. name: "drop with no blocked qTypes keeps only the hijack and the answer",
  36. raw: `{"outbounds":[{"protocol":"dns","tag":"dns-out","settings":{"nonIPQuery":"drop","blockTypes":[]}}]}`,
  37. wantChanged: true,
  38. wantOutbound: map[string]any{
  39. "protocol": "dns", "tag": "dns-out",
  40. "settings": map[string]any{
  41. "rules": []any{
  42. map[string]any{"action": "hijack", "qType": "1,28"},
  43. map[string]any{"action": "drop"},
  44. },
  45. },
  46. },
  47. },
  48. {
  49. name: "skip passes everything else through and keeps a lone qType a number",
  50. raw: `{"outbounds":[{"protocol":"dns","tag":"dns-out","settings":{"nonIPQuery":"skip","blockTypes":[28]}}]}`,
  51. wantChanged: true,
  52. wantOutbound: map[string]any{
  53. "protocol": "dns", "tag": "dns-out",
  54. "settings": map[string]any{
  55. "rules": []any{
  56. map[string]any{"action": "drop", "qType": float64(28)},
  57. map[string]any{"action": "hijack", "qType": "1,28"},
  58. map[string]any{"action": "direct"},
  59. },
  60. },
  61. },
  62. },
  63. {
  64. name: "the num field could hold a bare number or a string",
  65. raw: `{"outbounds":[{"protocol":"dns","tag":"dns-out","settings":{"nonIPQuery":"reject","blockTypes":"65, 28"}}]}`,
  66. wantChanged: true,
  67. wantOutbound: map[string]any{
  68. "protocol": "dns", "tag": "dns-out",
  69. "settings": map[string]any{
  70. "rules": []any{
  71. map[string]any{"action": "return", "qType": "65,28", "rCode": float64(5)},
  72. map[string]any{"action": "hijack", "qType": "1,28"},
  73. map[string]any{"action": "return", "rCode": float64(5)},
  74. },
  75. },
  76. },
  77. },
  78. {
  79. name: "a missing mode answered as reject, the core's default",
  80. raw: `{"outbounds":[{"protocol":"dns","tag":"dns-out","settings":{"blockTypes":[28]}}]}`,
  81. wantChanged: true,
  82. wantOutbound: map[string]any{
  83. "protocol": "dns", "tag": "dns-out",
  84. "settings": map[string]any{
  85. "rules": []any{
  86. map[string]any{"action": "return", "qType": float64(28), "rCode": float64(5)},
  87. map[string]any{"action": "hijack", "qType": "1,28"},
  88. map[string]any{"action": "return", "rCode": float64(5)},
  89. },
  90. },
  91. },
  92. },
  93. {
  94. name: "existing rules win, because the core refuses the mix",
  95. raw: `{"outbounds":[{"protocol":"dns","tag":"dns-out","settings":{"nonIPQuery":"drop","blockTypes":[28],"rules":[{"action":"hijack","qType":1}]}}]}`,
  96. wantChanged: true,
  97. wantOutbound: map[string]any{
  98. "protocol": "dns", "tag": "dns-out",
  99. "settings": map[string]any{
  100. "rules": []any{map[string]any{"action": "hijack", "qType": float64(1)}},
  101. },
  102. },
  103. },
  104. {
  105. name: "a null legacy pair is not a legacy config, as in the core",
  106. raw: `{"outbounds":[{"protocol":"dns","tag":"dns-out","settings":{"nonIPQuery":null,"blockTypes":null}}]}`,
  107. wantChanged: false,
  108. wantOutbound: map[string]any{
  109. "protocol": "dns", "tag": "dns-out",
  110. "settings": map[string]any{"nonIPQuery": nil, "blockTypes": nil},
  111. },
  112. },
  113. {
  114. name: "null rules leave the legacy pair authoritative",
  115. raw: `{"outbounds":[{"protocol":"dns","tag":"dns-out","settings":{"nonIPQuery":"drop","blockTypes":[28],"rules":null}}]}`,
  116. wantChanged: true,
  117. wantOutbound: map[string]any{
  118. "protocol": "dns", "tag": "dns-out",
  119. "settings": map[string]any{
  120. "rules": []any{
  121. map[string]any{"action": "drop", "qType": float64(28)},
  122. map[string]any{"action": "hijack", "qType": "1,28"},
  123. map[string]any{"action": "drop"},
  124. },
  125. },
  126. },
  127. },
  128. {
  129. name: "the core lowercases the protocol id it dispatches on",
  130. raw: `{"outbounds":[{"protocol":"DNS","tag":"dns-out","settings":{"nonIPQuery":"drop","blockTypes":[]}}]}`,
  131. wantChanged: true,
  132. wantOutbound: map[string]any{
  133. "protocol": "DNS", "tag": "dns-out",
  134. "settings": map[string]any{
  135. "rules": []any{
  136. map[string]any{"action": "hijack", "qType": "1,28"},
  137. map[string]any{"action": "drop"},
  138. },
  139. },
  140. },
  141. },
  142. {
  143. name: "a dns outbound already on rules is left alone",
  144. raw: `{"outbounds":[{"protocol":"dns","tag":"dns-out","settings":{"rules":[{"action":"hijack","qType":1}]}}]}`,
  145. wantChanged: false,
  146. wantOutbound: map[string]any{
  147. "protocol": "dns", "tag": "dns-out",
  148. "settings": map[string]any{
  149. "rules": []any{map[string]any{"action": "hijack", "qType": float64(1)}},
  150. },
  151. },
  152. },
  153. {
  154. name: "the same key names on another protocol are left alone",
  155. raw: `{"outbounds":[{"protocol":"freedom","tag":"direct","settings":{"nonIPQuery":"drop","blockTypes":[28]}}]}`,
  156. wantChanged: false,
  157. wantOutbound: map[string]any{
  158. "protocol": "freedom", "tag": "direct",
  159. "settings": map[string]any{"nonIPQuery": "drop", "blockTypes": []any{float64(28)}},
  160. },
  161. },
  162. }
  163. for _, tc := range tests {
  164. t.Run(tc.name, func(t *testing.T) {
  165. updated, changed, err := rewriteDNSOutboundLegacyKeys(tc.raw)
  166. if err != nil {
  167. t.Fatalf("unexpected error: %v", err)
  168. }
  169. if changed != tc.wantChanged {
  170. t.Fatalf("changed = %v, want %v", changed, tc.wantChanged)
  171. }
  172. var cfg struct {
  173. Outbounds []map[string]any `json:"outbounds"`
  174. }
  175. if err := json.Unmarshal([]byte(updated), &cfg); err != nil {
  176. t.Fatalf("rewritten template is not JSON: %v", err)
  177. }
  178. if len(cfg.Outbounds) != 1 {
  179. t.Fatalf("got %d outbounds, want 1", len(cfg.Outbounds))
  180. }
  181. got, _ := json.Marshal(cfg.Outbounds[0])
  182. want, _ := json.Marshal(tc.wantOutbound)
  183. if string(got) != string(want) {
  184. t.Fatalf("outbound = %s, want %s", got, want)
  185. }
  186. })
  187. }
  188. }
  189. type dnsCoreLogCapture struct{ msgs []string }
  190. func (c *dnsCoreLogCapture) Handle(msg corelog.Message) { c.msgs = append(c.msgs, msg.String()) }
  191. func (c *dnsCoreLogCapture) has(sub string) bool {
  192. return strings.Contains(strings.Join(c.msgs, "\n"), sub)
  193. }
  194. type dnsDiscardLogHandler struct{}
  195. func (dnsDiscardLogHandler) Handle(corelog.Message) {}
  196. func captureDNSCoreLogs(t *testing.T) *dnsCoreLogCapture {
  197. t.Helper()
  198. capture := new(dnsCoreLogCapture)
  199. corelog.RegisterHandler(capture)
  200. t.Cleanup(func() { corelog.RegisterHandler(dnsDiscardLogHandler{}) })
  201. return capture
  202. }
  203. // Drives the real core: the legacy keys warn on every load, rules next to them
  204. // are refused outright, and it reads JSON null the way this rewrite has to.
  205. func TestRewriteDNSOutboundLegacyKeysSatisfiesCore(t *testing.T) {
  206. for _, tc := range []struct {
  207. name string
  208. raw string
  209. wantLoadError bool
  210. wantLegacyWarning bool
  211. wantChanged bool
  212. }{
  213. {
  214. name: "deprecated keys",
  215. raw: `{"protocol":"dns","tag":"dns-out","settings":{"nonIPQuery":"reject","blockTypes":[65,28]}}`,
  216. wantLegacyWarning: true,
  217. wantChanged: true,
  218. },
  219. {
  220. name: "deprecated keys next to rules",
  221. raw: `{"protocol":"dns","tag":"dns-out","settings":{"nonIPQuery":"drop","blockTypes":[28],"rules":[{"action":"hijack","qType":1}]}}`,
  222. wantLoadError: true,
  223. wantChanged: true,
  224. },
  225. {
  226. name: "a null legacy pair warns about nothing and builds no policy",
  227. raw: `{"protocol":"dns","tag":"dns-out","settings":{"nonIPQuery":null,"blockTypes":null}}`,
  228. },
  229. {
  230. name: "null rules keep the legacy pair in charge, and it warns",
  231. raw: `{"protocol":"dns","tag":"dns-out","settings":{"nonIPQuery":"drop","blockTypes":[28],"rules":null}}`,
  232. wantLegacyWarning: true,
  233. wantChanged: true,
  234. },
  235. {
  236. name: "an upper-case protocol id is a dns outbound to the core",
  237. raw: `{"protocol":"DNS","tag":"dns-out","settings":{"nonIPQuery":"drop","blockTypes":[]}}`,
  238. wantLegacyWarning: true,
  239. wantChanged: true,
  240. },
  241. } {
  242. t.Run(tc.name, func(t *testing.T) {
  243. capture := captureDNSCoreLogs(t)
  244. if err := xray.ValidateOutboundConfig([]byte(tc.raw)); (err != nil) != tc.wantLoadError {
  245. t.Fatalf("legacy outbound load error = %v, want error: %v", err, tc.wantLoadError)
  246. }
  247. if capture.has("nonIPQuery") != tc.wantLegacyWarning {
  248. t.Fatalf("legacy warning = %v, want %v: %v", capture.has("nonIPQuery"), tc.wantLegacyWarning, capture.msgs)
  249. }
  250. updated, changed, err := rewriteDNSOutboundLegacyKeys(`{"outbounds":[` + tc.raw + `]}`)
  251. if err != nil || changed != tc.wantChanged {
  252. t.Fatalf("rewrite: changed=%v want %v err=%v", changed, tc.wantChanged, err)
  253. }
  254. if !changed {
  255. if !strings.Contains(updated, tc.raw) {
  256. t.Fatalf("unchanged outbound was rewritten: %s", updated)
  257. }
  258. return
  259. }
  260. var after struct {
  261. Outbounds []json.RawMessage `json:"outbounds"`
  262. }
  263. if err := json.Unmarshal([]byte(updated), &after); err != nil {
  264. t.Fatal(err)
  265. }
  266. capture.msgs = nil
  267. if err := xray.ValidateOutboundConfig(after.Outbounds[0]); err != nil {
  268. t.Fatalf("xray-core refused the rewritten outbound: %v", err)
  269. }
  270. if capture.has("nonIPQuery") {
  271. t.Fatalf("rewritten outbound still warns on load: %v", capture.msgs)
  272. }
  273. })
  274. }
  275. }
  276. func TestRewriteDNSOutboundLegacyKeysInvalidJSON(t *testing.T) {
  277. _, changed, err := rewriteDNSOutboundLegacyKeys("{not json")
  278. if err == nil {
  279. t.Fatal("expected an error for invalid JSON")
  280. }
  281. if changed {
  282. t.Fatal("invalid JSON must not report a change")
  283. }
  284. }
  285. func TestMigrateDNSOutboundLegacyKeysRewritesStoredTemplate(t *testing.T) {
  286. t.Setenv("XUI_DB_FOLDER", t.TempDir())
  287. // A CGO_ENABLED=0 build links a stubbed driver, so this test needs the same
  288. // C compiler the rest of the package's DB tests do.
  289. if err := InitDB(config.GetDBPath()); err != nil {
  290. if strings.Contains(err.Error(), "CGO_ENABLED=0") {
  291. t.Skipf("sqlite needs cgo: %v", err)
  292. }
  293. t.Fatalf("init db: %v", err)
  294. }
  295. t.Cleanup(func() { _ = CloseDB() })
  296. legacy := `{"outbounds":[{"protocol":"dns","tag":"dns-out","settings":{"nonIPQuery":"drop","blockTypes":[28]}}]}`
  297. seedDNSOutboundTemplate(t, legacy)
  298. if err := db.Where("seeder_name = ?", "DNSOutboundLegacyKeysFix").
  299. Delete(&model.HistoryOfSeeders{}).Error; err != nil {
  300. t.Fatalf("clear seeder history: %v", err)
  301. }
  302. if err := migrateDNSOutboundLegacyKeys(); err != nil {
  303. t.Fatalf("migrate: %v", err)
  304. }
  305. got := storedDNSOutboundTemplate(t)
  306. var cfg struct {
  307. Outbounds []map[string]any `json:"outbounds"`
  308. }
  309. if err := json.Unmarshal([]byte(got), &cfg); err != nil {
  310. t.Fatalf("stored template is not JSON: %v", err)
  311. }
  312. if len(cfg.Outbounds) != 1 {
  313. t.Fatalf("stored outbounds = %d, want 1", len(cfg.Outbounds))
  314. }
  315. settings, _ := cfg.Outbounds[0]["settings"].(map[string]any)
  316. if _, present := settings["nonIPQuery"]; present {
  317. t.Errorf("stored outbound kept nonIPQuery: %s", got)
  318. }
  319. if _, present := settings["blockTypes"]; present {
  320. t.Errorf("stored outbound kept blockTypes: %s", got)
  321. }
  322. rules, _ := settings["rules"].([]any)
  323. if len(rules) != 3 {
  324. t.Fatalf("stored rules = %s, want the three legacy rules in %s", settings["rules"], got)
  325. }
  326. // The history gate is what keeps a hand-edited template from being rewritten
  327. // again on every restart, so run the real seeder list over a fresh legacy one.
  328. seedDNSOutboundTemplate(t, legacy)
  329. if err := runSeeders(false); err != nil {
  330. t.Fatalf("runSeeders: %v", err)
  331. }
  332. if got := storedDNSOutboundTemplate(t); got != legacy {
  333. t.Errorf("a completed seeder rewrote the template again: %s", got)
  334. }
  335. }
  336. func seedDNSOutboundTemplate(t *testing.T, value string) {
  337. t.Helper()
  338. if err := db.Where("key = ?", "xrayTemplateConfig").Delete(&model.Setting{}).Error; err != nil {
  339. t.Fatalf("clear template: %v", err)
  340. }
  341. if err := db.Create(&model.Setting{Key: "xrayTemplateConfig", Value: value}).Error; err != nil {
  342. t.Fatalf("seed template: %v", err)
  343. }
  344. }
  345. func storedDNSOutboundTemplate(t *testing.T) string {
  346. t.Helper()
  347. var setting model.Setting
  348. if err := db.Where("key = ?", "xrayTemplateConfig").First(&setting).Error; err != nil {
  349. t.Fatalf("reload template: %v", err)
  350. }
  351. return setting.Value
  352. }