10 Incheckningar 39ce7cbc22 ... a810f497e6

Upphovsman SHA1 Meddelande Datum
  BlindMaster24 a810f497e6 fix(xray): read the last two inboundTag protocol ids like the core (#6530) 15 timmar sedan
  Sanaei 837addf66e v3.8.0 16 timmar sedan
  Sanaei 840a40edcd chore(deps): update frontend and Go deps 16 timmar sedan
  BlindMaster24 c0271e231d fix(panel): read the outbound protocol id in the Outbounds row like the core (#6528) 17 timmar sedan
  BlindMaster24 efcf152950 fix(outbound): read the probe testability gate's ids like the core (#6527) 17 timmar sedan
  BlindMaster24 f69d1e869d fix(outbound): read the probe protocol id and transport name like the core (#6526) 17 timmar sedan
  BlindMaster24 4d6db1c961 fix(xray): read an outbound protocol id the way the core does (#6521) 17 timmar sedan
  BlindMaster24 84c5aef4a1 fix(panel): probe UDP outbounds and hide the block outbound from the mtproto egress picker (#6525) 19 timmar sedan
  BlindMaster24 a5a4c9cd83 fix(panel): read an outbound protocol id the way the core does (#6522) 19 timmar sedan
  BlindMaster24 c0c2dd274c fix(panel): read outbound protocol ids case-insensitively everywhere (#6523) 20 timmar sedan
36 ändrade filer med 1433 tillägg och 266 borttagningar
  1. 188 182
      frontend/package-lock.json
  2. 6 6
      frontend/package.json
  3. 3 2
      frontend/src/api/queries/useOutboundTags.ts
  4. 22 12
      frontend/src/hooks/useXraySetting.ts
  5. 3 1
      frontend/src/lib/xray/outbound-form-adapter.ts
  6. 2 1
      frontend/src/pages/inbounds/form/protocols/mtproto.tsx
  7. 2 1
      frontend/src/pages/index/GeodataSection.tsx
  8. 2 1
      frontend/src/pages/settings/GeneralTab.tsx
  9. 14 10
      frontend/src/pages/xray/basics/BasicsTab.tsx
  10. 28 10
      frontend/src/pages/xray/basics/helpers.ts
  11. 2 1
      frontend/src/pages/xray/outbounds/OutboundsTab.tsx
  12. 21 14
      frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts
  13. 3 3
      frontend/src/pages/xray/outbounds/useOutboundColumns.tsx
  14. 10 0
      frontend/src/schemas/primitives/outbound-protocol.ts
  15. 35 0
      frontend/src/test/basics-freedom-strategy.test.ts
  16. 44 0
      frontend/src/test/basics-tab-direct-tag.test.tsx
  17. 58 0
      frontend/src/test/mtproto-egress-picker.test.tsx
  18. 26 0
      frontend/src/test/outbound-form-adapter.test.ts
  19. 60 0
      frontend/src/test/outbound-identity-tags-case.test.tsx
  20. 28 0
      frontend/src/test/outbound-protocol-case.test.ts
  21. 73 0
      frontend/src/test/outbounds-addresses-case.test.ts
  22. 26 0
      frontend/src/test/outbounds-testability-gate.test.ts
  23. 67 0
      frontend/src/test/use-outbound-tags.test.tsx
  24. 45 0
      frontend/src/test/use-xray-setting.test.tsx
  25. 3 3
      go.mod
  26. 6 6
      go.sum
  27. 1 1
      internal/config/version
  28. 82 5
      internal/database/db.go
  29. 5 0
      internal/database/freedom_domain_strategy_migration_test.go
  30. 249 0
      internal/database/rewriter_protocol_case_test.go
  31. 17 3
      internal/web/service/outbound/outbound.go
  32. 4 2
      internal/web/service/outbound/probe_http.go
  33. 174 0
      internal/web/service/outbound/probe_protocol_case_test.go
  34. 54 0
      internal/web/service/outbound/probe_testability_gate_test.go
  35. 3 2
      internal/web/service/xray_setting_routing_sync.go
  36. 67 0
      internal/web/service/xray_setting_routing_sync_test.go

+ 188 - 182
frontend/package-lock.json

@@ -15,7 +15,7 @@
         "@noble/hashes": "^2.4.0",
         "@noble/hashes": "^2.4.0",
         "@tanstack/react-query": "^5.102.8",
         "@tanstack/react-query": "^5.102.8",
         "@tanstack/react-query-devtools": "^5.102.8",
         "@tanstack/react-query-devtools": "^5.102.8",
-        "antd": "^6.6.3",
+        "antd": "^6.6.4",
         "codemirror": "^6.0.2",
         "codemirror": "^6.0.2",
         "dayjs": "^1.11.23",
         "dayjs": "^1.11.23",
         "i18next": "^26.4.2",
         "i18next": "^26.4.2",
@@ -24,18 +24,18 @@
         "react": "^19.3.0",
         "react": "^19.3.0",
         "react-dom": "^19.3.0",
         "react-dom": "^19.3.0",
         "react-hook-form": "^7.88.0",
         "react-hook-form": "^7.88.0",
-        "react-i18next": "^17.0.13",
+        "react-i18next": "^17.0.14",
         "react-router": "^8.3.1",
         "react-router": "^8.3.1",
         "swagger-ui-react": "^5.32.15",
         "swagger-ui-react": "^5.32.15",
         "uplot": "^1.6.32",
         "uplot": "^1.6.32",
-        "zod": "^4.6.2"
+        "zod": "^4.6.5"
       },
       },
       "devDependencies": {
       "devDependencies": {
         "@storybook/addon-a11y": "^10.6.0",
         "@storybook/addon-a11y": "^10.6.0",
         "@storybook/addon-docs": "^10.6.0",
         "@storybook/addon-docs": "^10.6.0",
         "@storybook/addon-vitest": "^10.6.0",
         "@storybook/addon-vitest": "^10.6.0",
         "@storybook/react-vite": "^10.6.0",
         "@storybook/react-vite": "^10.6.0",
-        "@testing-library/dom": "^10.4.1",
+        "@testing-library/dom": "^10.4.2",
         "@testing-library/react": "^16.3.3",
         "@testing-library/react": "^16.3.3",
         "@types/react": "^19.3.0",
         "@types/react": "^19.3.0",
         "@types/react-dom": "^19.3.0",
         "@types/react-dom": "^19.3.0",
@@ -47,8 +47,8 @@
         "jsdom": "^30.0.1",
         "jsdom": "^30.0.1",
         "lint-staged": "^17.5.1",
         "lint-staged": "^17.5.1",
         "msw": "^2.15.0",
         "msw": "^2.15.0",
-        "oxfmt": "0.67.0",
-        "oxlint": "1.82.0",
+        "oxfmt": "0.68.0",
+        "oxlint": "1.83.0",
         "oxlint-tsgolint": "^7.0.2001",
         "oxlint-tsgolint": "^7.0.2001",
         "playwright": "^1.63.0",
         "playwright": "^1.63.0",
         "storybook": "^10.6.0",
         "storybook": "^10.6.0",
@@ -2360,9 +2360,9 @@
       ]
       ]
     },
     },
     "node_modules/@oxfmt/binding-android-arm-eabi": {
     "node_modules/@oxfmt/binding-android-arm-eabi": {
-      "version": "0.67.0",
-      "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.67.0.tgz",
-      "integrity": "sha512-2olh3ioEmc4gRzQm7jxyB1b/PFBoFvTq8KdgYySeNpysDtA6DEg2Mvya4/I6flhL7G0eOrE8RD7JCNCIMhE16Q==",
+      "version": "0.68.0",
+      "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.68.0.tgz",
+      "integrity": "sha512-dhfYPbzv/h9JgHjNkl2R6sOjUfxDyLGOZVb3g8/ScaTNwwJcYgmHh8kcYFDUhinuy1QAoANCWUvw1jlk+z6gAg==",
       "cpu": [
       "cpu": [
         "arm"
         "arm"
       ],
       ],
@@ -2377,9 +2377,9 @@
       }
       }
     },
     },
     "node_modules/@oxfmt/binding-android-arm64": {
     "node_modules/@oxfmt/binding-android-arm64": {
-      "version": "0.67.0",
-      "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.67.0.tgz",
-      "integrity": "sha512-ulfw8EHN1MBq/MFFDXw2/M1VAFu5mRUcnuZ8Hqbv9viAnFzO9t1jKSAsDqKYYDGMlytF/uj6Z5z5n/tHupnKhw==",
+      "version": "0.68.0",
+      "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.68.0.tgz",
+      "integrity": "sha512-v3Njdi6qY0O/5eGfg01ww2w6gTn2mUvZ72Bnx1/UN53A9wruh3Nk6otc3WkgJLkXD4Qgz1SOcVQieH1oD03V9Q==",
       "cpu": [
       "cpu": [
         "arm64"
         "arm64"
       ],
       ],
@@ -2394,9 +2394,9 @@
       }
       }
     },
     },
     "node_modules/@oxfmt/binding-darwin-arm64": {
     "node_modules/@oxfmt/binding-darwin-arm64": {
-      "version": "0.67.0",
-      "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.67.0.tgz",
-      "integrity": "sha512-MfONZx/O2o9M5v2jDFol556G9+A+P9xCuJ4DZ+qhE+RnaCdoscy6Eu5nq1dbuNxhwdJyZ6kLI7fnG9mwEeOeGg==",
+      "version": "0.68.0",
+      "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.68.0.tgz",
+      "integrity": "sha512-ei4MCMzHFREmZwPJ7KuWUB4kBuHdsgDrnXGJVcEAopU7fj7S42I8BChdFILWdHvhFqR08FLJtOfbZIr2CDw0cA==",
       "cpu": [
       "cpu": [
         "arm64"
         "arm64"
       ],
       ],
@@ -2411,9 +2411,9 @@
       }
       }
     },
     },
     "node_modules/@oxfmt/binding-darwin-x64": {
     "node_modules/@oxfmt/binding-darwin-x64": {
-      "version": "0.67.0",
-      "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.67.0.tgz",
-      "integrity": "sha512-CYnIx5LvFVJnyJcCqwH2jxMKjFjqo5678MPjdmNFoSGMhlOvZ/xRZqvhDcolKrXc8fezW3AKh+C4wyoFuWOSSg==",
+      "version": "0.68.0",
+      "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.68.0.tgz",
+      "integrity": "sha512-UrKgzZxYhwB9DSvTX+vdgl9M32wLUNKJcAKIoiyx/Kzn/zveqi7W6kYVcRroFMhS3Kwz0KhTk3WBeSuQn4YCTg==",
       "cpu": [
       "cpu": [
         "x64"
         "x64"
       ],
       ],
@@ -2428,9 +2428,9 @@
       }
       }
     },
     },
     "node_modules/@oxfmt/binding-freebsd-x64": {
     "node_modules/@oxfmt/binding-freebsd-x64": {
-      "version": "0.67.0",
-      "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.67.0.tgz",
-      "integrity": "sha512-7/iF1orvIS9mxhKUqnmtMgm+OrSQ5acPwuvdQrm6ECgqbwPmC+Pw9cdke3sNfVN6pT2hbJ58+jP8BCThl5HXOg==",
+      "version": "0.68.0",
+      "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.68.0.tgz",
+      "integrity": "sha512-6jrEKgpJbilM1QaRv7hEtKXr4p4AK4jvvyOtajwyhu0kOz3e0O7OLnSTk6tBotRqCcUC4ehZRJ1Zx+Y99wieLw==",
       "cpu": [
       "cpu": [
         "x64"
         "x64"
       ],
       ],
@@ -2445,9 +2445,9 @@
       }
       }
     },
     },
     "node_modules/@oxfmt/binding-linux-arm-gnueabihf": {
     "node_modules/@oxfmt/binding-linux-arm-gnueabihf": {
-      "version": "0.67.0",
-      "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.67.0.tgz",
-      "integrity": "sha512-yy+OGys07IZOpOmYPZoObKyUQLkfxeQqeCypk+1jaZd8HGo77hzvU1Jg8X3+W75o+9lszOjBfg0nkGtlwYywXw==",
+      "version": "0.68.0",
+      "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.68.0.tgz",
+      "integrity": "sha512-YOIVnKOBaLeGullskS179N12hjSAdFYnzLjOaKiLhAKNWgnShq9w4xRdtmUm6BlnP65l2/EA9Aw/KlftNxDM7Q==",
       "cpu": [
       "cpu": [
         "arm"
         "arm"
       ],
       ],
@@ -2462,9 +2462,9 @@
       }
       }
     },
     },
     "node_modules/@oxfmt/binding-linux-arm-musleabihf": {
     "node_modules/@oxfmt/binding-linux-arm-musleabihf": {
-      "version": "0.67.0",
-      "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.67.0.tgz",
-      "integrity": "sha512-wPIeeigXgJpwNw3wydYRt3U9iN9Y/ejpOZuYL9IA7igxWs7LIQMOkhKxTumRvy6dIv0iXKk3RTw3Vmjg0i+2sg==",
+      "version": "0.68.0",
+      "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.68.0.tgz",
+      "integrity": "sha512-xW5XoEHVNqydPBv2KXvk9lmEzyAlOQHVEazKoXUuAacqekjya+OdiaFjjEBl0oJD02raG8g3TRl9OVCh9PDIHA==",
       "cpu": [
       "cpu": [
         "arm"
         "arm"
       ],
       ],
@@ -2479,9 +2479,9 @@
       }
       }
     },
     },
     "node_modules/@oxfmt/binding-linux-arm64-gnu": {
     "node_modules/@oxfmt/binding-linux-arm64-gnu": {
-      "version": "0.67.0",
-      "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.67.0.tgz",
-      "integrity": "sha512-0+XNxcdbkTfxdcD4qW6Ci9n+mBNJ8xTBumnxKvKBmRFOdx0Wf8/KiHjCJayooXmYkqRpRVd98Q5egvzx5BLSgQ==",
+      "version": "0.68.0",
+      "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.68.0.tgz",
+      "integrity": "sha512-QCvYwVVQieu6oyJglAgV9vH/YMDxZyR4cwVSYtoq9oOXd5N+D3TDUBjNwxFrLn5AdcJZOcvn/7IB17vJGp+2Og==",
       "cpu": [
       "cpu": [
         "arm64"
         "arm64"
       ],
       ],
@@ -2499,9 +2499,9 @@
       }
       }
     },
     },
     "node_modules/@oxfmt/binding-linux-arm64-musl": {
     "node_modules/@oxfmt/binding-linux-arm64-musl": {
-      "version": "0.67.0",
-      "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.67.0.tgz",
-      "integrity": "sha512-I75LKPJyNOYUzkqAiAMIE31+Ye7xtQXZdoty1IXn4B+bw5Zpmez5wfG19ejGpNnS/BzQ7LFS+7jxuTPb+vHiZw==",
+      "version": "0.68.0",
+      "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.68.0.tgz",
+      "integrity": "sha512-4TVz5iFQ8ndrHnhX50UXiz9BIWAtUSOHJ6Nus4qWFfJBXq/Ed/krXbF/ehJu42BXM5tIvBs99jNIM22s9agY5A==",
       "cpu": [
       "cpu": [
         "arm64"
         "arm64"
       ],
       ],
@@ -2519,9 +2519,9 @@
       }
       }
     },
     },
     "node_modules/@oxfmt/binding-linux-ppc64-gnu": {
     "node_modules/@oxfmt/binding-linux-ppc64-gnu": {
-      "version": "0.67.0",
-      "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.67.0.tgz",
-      "integrity": "sha512-c2M5iRpe1QMZSRE/UvZoPdXBWb5Ic/ycvOyNiKCqPwQ/OyOKIMiJs02ynlNnjb7ZZJnRXYLmGcohoINOcwDK3w==",
+      "version": "0.68.0",
+      "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.68.0.tgz",
+      "integrity": "sha512-qLe3ao0RP84bnPxBvRI+GnlK/jybo538NWu0Xrm+zYeTmJtpzqLhnnd5BH21NafqKnplbGZjtN1cnrOlWF73lw==",
       "cpu": [
       "cpu": [
         "ppc64"
         "ppc64"
       ],
       ],
@@ -2539,9 +2539,9 @@
       }
       }
     },
     },
     "node_modules/@oxfmt/binding-linux-riscv64-gnu": {
     "node_modules/@oxfmt/binding-linux-riscv64-gnu": {
-      "version": "0.67.0",
-      "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.67.0.tgz",
-      "integrity": "sha512-dQzzYlV24Udhfm5ECuSdgqRvFJU/CGHzcYYEO3dLM6W6+CHiBFrq9OjIllkdCcPhsoSQ8o223Dja84MOSzed9A==",
+      "version": "0.68.0",
+      "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.68.0.tgz",
+      "integrity": "sha512-Yvyl7a6gbb0vM6r925KW2dO+/CmXySO5TVbcX7o/uZJ+d108HFOG0TxIyHApmn5USuj5mhDPxzhiVwOlGP7uSA==",
       "cpu": [
       "cpu": [
         "riscv64"
         "riscv64"
       ],
       ],
@@ -2559,9 +2559,9 @@
       }
       }
     },
     },
     "node_modules/@oxfmt/binding-linux-riscv64-musl": {
     "node_modules/@oxfmt/binding-linux-riscv64-musl": {
-      "version": "0.67.0",
-      "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.67.0.tgz",
-      "integrity": "sha512-rFNq1CgX4qMJANOq42LkAs90JE80GpiaEohAV2qn/gT2hGjQTW1zBO5zQBxArI4926pM1OSzo3CN0tBszGBIaA==",
+      "version": "0.68.0",
+      "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.68.0.tgz",
+      "integrity": "sha512-mJlFuFVCxzrYM5sFStN433D/s/mb6Wq4aCQAM02vs/OudHywnaSAd2rb1vlYUJtqdYIciJtiasuxvfbYkv5fLg==",
       "cpu": [
       "cpu": [
         "riscv64"
         "riscv64"
       ],
       ],
@@ -2579,9 +2579,9 @@
       }
       }
     },
     },
     "node_modules/@oxfmt/binding-linux-s390x-gnu": {
     "node_modules/@oxfmt/binding-linux-s390x-gnu": {
-      "version": "0.67.0",
-      "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.67.0.tgz",
-      "integrity": "sha512-Sky6rEdz2o5IGq01lPhS12yEvDdChVEcaYrcLHkveh4Fx0qPjljE/Iul6SX/bRMl6lNc8J7J/mDQdzgBdA++Pg==",
+      "version": "0.68.0",
+      "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.68.0.tgz",
+      "integrity": "sha512-RlfSg++qs1hbKltRR6lYvV9EoI3MdlfSQD9w1hdHVYjHqjIn1tkH4FWOpMSmjKGN20zr+nI+W9o4ARogCDudGQ==",
       "cpu": [
       "cpu": [
         "s390x"
         "s390x"
       ],
       ],
@@ -2599,9 +2599,9 @@
       }
       }
     },
     },
     "node_modules/@oxfmt/binding-linux-x64-gnu": {
     "node_modules/@oxfmt/binding-linux-x64-gnu": {
-      "version": "0.67.0",
-      "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.67.0.tgz",
-      "integrity": "sha512-vPXmlNORV8AZq2Ocxh07pxwMjfENUWCV/eZArnao0qC3NO/hDeTVkQvee7SJJUbIiF5PZbBa4kYmaXnu7Rk58w==",
+      "version": "0.68.0",
+      "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.68.0.tgz",
+      "integrity": "sha512-nyzRB9U+dlYUKu3pMo3afHzZBUv/oTHZMG36ZfJViNVfOIzp70Q4GS8FFRGgYJ/p0zcyDCgpBvYISOdJOMh+jQ==",
       "cpu": [
       "cpu": [
         "x64"
         "x64"
       ],
       ],
@@ -2619,9 +2619,9 @@
       }
       }
     },
     },
     "node_modules/@oxfmt/binding-linux-x64-musl": {
     "node_modules/@oxfmt/binding-linux-x64-musl": {
-      "version": "0.67.0",
-      "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.67.0.tgz",
-      "integrity": "sha512-x/WAtFqYtVr3vZ9ni8nr4kn9whSitg8fOljq/pZzBpxopRdY1BMLZCZkrbIbaBcYkm46qGbqVea2FCWmtQ2P9w==",
+      "version": "0.68.0",
+      "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.68.0.tgz",
+      "integrity": "sha512-iCx3sbZRIvGrL1RafphEiUKBaW1lc0/tAjKOIB/Wjw2+STRBEdu5+fH1Gc1faEWEmc2k5Ks4iUUV54Zd5C9a1A==",
       "cpu": [
       "cpu": [
         "x64"
         "x64"
       ],
       ],
@@ -2639,9 +2639,9 @@
       }
       }
     },
     },
     "node_modules/@oxfmt/binding-openharmony-arm64": {
     "node_modules/@oxfmt/binding-openharmony-arm64": {
-      "version": "0.67.0",
-      "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.67.0.tgz",
-      "integrity": "sha512-eRw9Neh4/aA6i+q/R3WU1gGQINhVM0J4fXIm6t27caOamkr/37uAkp1IdBx4zlJH97hmXR63z/q9n5c5dN7MzA==",
+      "version": "0.68.0",
+      "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.68.0.tgz",
+      "integrity": "sha512-x2X5AZez7OgyLLFpwIgItoXBUqudDM7yiaTsxv8R8vKQ6e81l0jVw0NFeUCXzcl1sAJq8h+tC8N4mY8EiMeL4w==",
       "cpu": [
       "cpu": [
         "arm64"
         "arm64"
       ],
       ],
@@ -2656,9 +2656,9 @@
       }
       }
     },
     },
     "node_modules/@oxfmt/binding-win32-arm64-msvc": {
     "node_modules/@oxfmt/binding-win32-arm64-msvc": {
-      "version": "0.67.0",
-      "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.67.0.tgz",
-      "integrity": "sha512-YIMvb+sGNYN2uc6+QK2HLPeEKM2vl7QZ5onQzpAJRb6pnf0DwUFP5R8tdS9R0l8hdUil2gu4Uxd0Yxrop0iT4w==",
+      "version": "0.68.0",
+      "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.68.0.tgz",
+      "integrity": "sha512-AHVPjXkenLPQUh6kB8zSC8pX2ct9r4T1Edk9r/RNJyov6wPsS5uAfYtipwG4chn6+3bPFG5rI/3DxEeH8vib1w==",
       "cpu": [
       "cpu": [
         "arm64"
         "arm64"
       ],
       ],
@@ -2673,9 +2673,9 @@
       }
       }
     },
     },
     "node_modules/@oxfmt/binding-win32-ia32-msvc": {
     "node_modules/@oxfmt/binding-win32-ia32-msvc": {
-      "version": "0.67.0",
-      "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.67.0.tgz",
-      "integrity": "sha512-LzmU9MyACPzwNDIK0ItMedHPz735Ug7ELWguxo4/kuy6zWuDoeglOAEFCY8jLg0PzRpFO3hDyLFe2Gu2eFDeGA==",
+      "version": "0.68.0",
+      "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.68.0.tgz",
+      "integrity": "sha512-n09SjEk5VH7z8Hl4WVP7hho+cCwGViENkQFiM45vbW85dJd7kEhWaHRUobaPzEmrWyu6uumd4EuNfNyDKLtzDA==",
       "cpu": [
       "cpu": [
         "ia32"
         "ia32"
       ],
       ],
@@ -2690,9 +2690,9 @@
       }
       }
     },
     },
     "node_modules/@oxfmt/binding-win32-x64-msvc": {
     "node_modules/@oxfmt/binding-win32-x64-msvc": {
-      "version": "0.67.0",
-      "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.67.0.tgz",
-      "integrity": "sha512-sbQOIDNLUEeVZcAJcSL5VURn7kfjvilPviody4Yl5n8lQCDtUm+C9oHTTwZS/m4d/Z6Vv3jNEiAofH932NPPCg==",
+      "version": "0.68.0",
+      "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.68.0.tgz",
+      "integrity": "sha512-gPe+dJLXaPuWPWqlpklDAJp0k+K9KhQPYiQLHfb+i2rmFuUGfJ/5Qlj6tr1mO6of5g0DiLjG/XCFHIaPhotqqA==",
       "cpu": [
       "cpu": [
         "x64"
         "x64"
       ],
       ],
@@ -2791,9 +2791,9 @@
       ]
       ]
     },
     },
     "node_modules/@oxlint/binding-android-arm-eabi": {
     "node_modules/@oxlint/binding-android-arm-eabi": {
-      "version": "1.82.0",
-      "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.82.0.tgz",
-      "integrity": "sha512-a3LB+C5Dsj5b/qtmG/mv5WrzuiXEpg1KF5nXWcEvaoN5TYAqkIvxPOwTPp3Jy/FoGpRo8zsTFhMElMXfeoOEzA==",
+      "version": "1.83.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.83.0.tgz",
+      "integrity": "sha512-0yGY24EwsLk5YDe6F+VkmZyRHSwJDALa3nIrPpq7FXmp2lV2d0TzvBCGeZk+wgiULRGr5blhyr4QMp5KCXJUqA==",
       "cpu": [
       "cpu": [
         "arm"
         "arm"
       ],
       ],
@@ -2808,9 +2808,9 @@
       }
       }
     },
     },
     "node_modules/@oxlint/binding-android-arm64": {
     "node_modules/@oxlint/binding-android-arm64": {
-      "version": "1.82.0",
-      "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.82.0.tgz",
-      "integrity": "sha512-OBlhRgNqFblGpGenno/aqOfJLOkQ2B8Ig3iDAalfn0H8hJGZKXPeexCRTDm6uwv6YUjSA9Xnwt1y/Bgj5ZH8uw==",
+      "version": "1.83.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.83.0.tgz",
+      "integrity": "sha512-hHfJ0vc17A4iUjH5p9BsTUPYbYRNxGpvD2lbu1aBRk54bzNIx9o5TtYF39QPZcV95DagZd+4DEAw2RH3G2ZsMg==",
       "cpu": [
       "cpu": [
         "arm64"
         "arm64"
       ],
       ],
@@ -2825,9 +2825,9 @@
       }
       }
     },
     },
     "node_modules/@oxlint/binding-darwin-arm64": {
     "node_modules/@oxlint/binding-darwin-arm64": {
-      "version": "1.82.0",
-      "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.82.0.tgz",
-      "integrity": "sha512-dsopxqtY5ZdyT9uLHyGt1SyiLop6hi7hWI3PKpePodkRQOkLaCm+OE4fR9CAz9qdfjiFO8531tX/QDyP/psjFg==",
+      "version": "1.83.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.83.0.tgz",
+      "integrity": "sha512-hsOjYjszLb/3zym/TkzUMPAoQlTJcuzSyEPOAyA+skXJIX9M0o+4JfOtqopX/Vf4hSLrJ98j0nvFo23gzk8auQ==",
       "cpu": [
       "cpu": [
         "arm64"
         "arm64"
       ],
       ],
@@ -2842,9 +2842,9 @@
       }
       }
     },
     },
     "node_modules/@oxlint/binding-darwin-x64": {
     "node_modules/@oxlint/binding-darwin-x64": {
-      "version": "1.82.0",
-      "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.82.0.tgz",
-      "integrity": "sha512-94Lu0SgTClKColU66g1VDuigV3HkcbkJBnTtZjGYfE8UPugaWDgKrm2icjC6HJVUYler2OXaHP/X0TBy8+CowQ==",
+      "version": "1.83.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.83.0.tgz",
+      "integrity": "sha512-mjh5oH2EA+wl5yRJYT9K9G61O2zFlpuv+yf2JwZOi0+dq2FnTUtm1h8i+5Ik0fXPWIu/k84I1psZR9aQsLAnyA==",
       "cpu": [
       "cpu": [
         "x64"
         "x64"
       ],
       ],
@@ -2859,9 +2859,9 @@
       }
       }
     },
     },
     "node_modules/@oxlint/binding-freebsd-x64": {
     "node_modules/@oxlint/binding-freebsd-x64": {
-      "version": "1.82.0",
-      "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.82.0.tgz",
-      "integrity": "sha512-hne/V06ewhh1i0w8+l7GDNROAGCGPmyFuOwiP7YTRu0JycyStJ4785dmF8xU5p0uUwt2emvIF9vc7Xjis+cJ0g==",
+      "version": "1.83.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.83.0.tgz",
+      "integrity": "sha512-fNHr64/YaO8YssuoDVC8+F4Uk5enR86q5uxfHkQrjAPs1dbAILOrD2uaud+J7MO8Fx774g44ERLD0IGIvZE48w==",
       "cpu": [
       "cpu": [
         "x64"
         "x64"
       ],
       ],
@@ -2876,9 +2876,9 @@
       }
       }
     },
     },
     "node_modules/@oxlint/binding-linux-arm-gnueabihf": {
     "node_modules/@oxlint/binding-linux-arm-gnueabihf": {
-      "version": "1.82.0",
-      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.82.0.tgz",
-      "integrity": "sha512-aWY2xtbZf1LneW9Qsv/n2Sp8gOu74JrlQzEtj4coHX2SHFrCfhmAumaU+sI/A5nr+yoTRTSmI/pL2s6ADlNSkw==",
+      "version": "1.83.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.83.0.tgz",
+      "integrity": "sha512-Qpwy3zzAwMj+8/lyYItHmkSMwbkprFNWTK7jPYDOxSyxEhaSLOWYUTCMkjF334J8/WD0nznCCsoBbIH6hpsuIw==",
       "cpu": [
       "cpu": [
         "arm"
         "arm"
       ],
       ],
@@ -2893,9 +2893,9 @@
       }
       }
     },
     },
     "node_modules/@oxlint/binding-linux-arm-musleabihf": {
     "node_modules/@oxlint/binding-linux-arm-musleabihf": {
-      "version": "1.82.0",
-      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.82.0.tgz",
-      "integrity": "sha512-Fe+TtXCXMh/5f7kWlZ2VAwsMumZWtraFlKVk1NJlL52/beGwfDE7ov+/8gVirHzWokzGu7X65hSPq0ucPDskWQ==",
+      "version": "1.83.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.83.0.tgz",
+      "integrity": "sha512-s+BirYLFq7JL2k9sP0XI3ZXJ9dYvJ8sX3jLCLoag7tt+zrSHpZxP0jqznfL+Gdgwu7ay0dYgGYJXrQvq3iWloA==",
       "cpu": [
       "cpu": [
         "arm"
         "arm"
       ],
       ],
@@ -2910,9 +2910,9 @@
       }
       }
     },
     },
     "node_modules/@oxlint/binding-linux-arm64-gnu": {
     "node_modules/@oxlint/binding-linux-arm64-gnu": {
-      "version": "1.82.0",
-      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.82.0.tgz",
-      "integrity": "sha512-6azCZ6OJudlvipNttXCCQcyeFfcJ/NvUZdSN1z8elo73kCHtyQC7WTiUcSjWYvJ1jaq9KDUyMAoAS/vNzhBomA==",
+      "version": "1.83.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.83.0.tgz",
+      "integrity": "sha512-7lihXt3vKr+GIyapNbHrnFHm/biiW30le6Zv/DExbAFPF6YwCQXVFlONPFehxs0CpGO4CBfYPM9rdDT+XMoIlg==",
       "cpu": [
       "cpu": [
         "arm64"
         "arm64"
       ],
       ],
@@ -2930,9 +2930,9 @@
       }
       }
     },
     },
     "node_modules/@oxlint/binding-linux-arm64-musl": {
     "node_modules/@oxlint/binding-linux-arm64-musl": {
-      "version": "1.82.0",
-      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.82.0.tgz",
-      "integrity": "sha512-PLEaSD8IAIIlwW4dwOd9YaxuxeOpwiXL4J24rcnE4iNtyM5j9Q9/3+gti08oXpx0u2ygNjRDx9xjWWpQonuJEw==",
+      "version": "1.83.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.83.0.tgz",
+      "integrity": "sha512-q63JalLYVkZiZvls1z3PPUnpmQluOMXp0khqQMznCeAPLGydfNY8JhvuA4WlK57JfrvikU8wB5lPVveqpIXvew==",
       "cpu": [
       "cpu": [
         "arm64"
         "arm64"
       ],
       ],
@@ -2950,9 +2950,9 @@
       }
       }
     },
     },
     "node_modules/@oxlint/binding-linux-ppc64-gnu": {
     "node_modules/@oxlint/binding-linux-ppc64-gnu": {
-      "version": "1.82.0",
-      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.82.0.tgz",
-      "integrity": "sha512-D94em/BwknNTn4vqxjHh5wb2oL566eFhArabqKIr0cNZMHOJuiraFp1A8tXpH05bbE5tqwEfLXTI0MWEGtn3Dw==",
+      "version": "1.83.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.83.0.tgz",
+      "integrity": "sha512-krQmDF+dRbxvdqVPV88ZuOoPPu8X5BuqDA8Hd+qcS4YMRQCb+nexA57DazgGsc/rGdKBe3QmV0mnv0bdpW/p5g==",
       "cpu": [
       "cpu": [
         "ppc64"
         "ppc64"
       ],
       ],
@@ -2970,9 +2970,9 @@
       }
       }
     },
     },
     "node_modules/@oxlint/binding-linux-riscv64-gnu": {
     "node_modules/@oxlint/binding-linux-riscv64-gnu": {
-      "version": "1.82.0",
-      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.82.0.tgz",
-      "integrity": "sha512-MOprxBaoYU2D4VgxXCl3ghydThWtx7Um1lL51kGYNeQ5Al7WzsH7/tqGdNtbLrIWnjq3bsm13+nz/gRIxjrOXw==",
+      "version": "1.83.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.83.0.tgz",
+      "integrity": "sha512-MmOl8Y6txEAXZU1RG8Rr264jQ6D7VPmqFsU/45x/FeWsGe32hklTqGrLE6UxHzp5Rjt0wP+20tY8YXKgSFB3mw==",
       "cpu": [
       "cpu": [
         "riscv64"
         "riscv64"
       ],
       ],
@@ -2990,9 +2990,9 @@
       }
       }
     },
     },
     "node_modules/@oxlint/binding-linux-riscv64-musl": {
     "node_modules/@oxlint/binding-linux-riscv64-musl": {
-      "version": "1.82.0",
-      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.82.0.tgz",
-      "integrity": "sha512-5h55QsfJ/luDXZzC20k6SNOY1Az+dCP9WvntKtcUWh2JhckAdwApY2ZusaBTwLENnReXU+A2fJtSrYvZJNKNPg==",
+      "version": "1.83.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.83.0.tgz",
+      "integrity": "sha512-u1rMymh0W3JZkq370kzQsYPULGWqhE09pZRqnZvUSoYaI9pVO5yVX+iYIslmWuEgwuzH9YAaOsScJiobWCHoOw==",
       "cpu": [
       "cpu": [
         "riscv64"
         "riscv64"
       ],
       ],
@@ -3010,9 +3010,9 @@
       }
       }
     },
     },
     "node_modules/@oxlint/binding-linux-s390x-gnu": {
     "node_modules/@oxlint/binding-linux-s390x-gnu": {
-      "version": "1.82.0",
-      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.82.0.tgz",
-      "integrity": "sha512-IE8NJNLlHr0CaXyGJPGVn0eTkUyoj1I2UfA8x7I4PSOYKsQ/6btVC7Pywrj5onk0cMH25r6Z38SoN3AvE5Zuog==",
+      "version": "1.83.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.83.0.tgz",
+      "integrity": "sha512-y0zK3HNwGysu7rqtE+BQG/d0bx5gh/KwlOtghN8oWeK1KcWzeaLqtZrbm8owqdma1lFyrce/hTO5ismuNu+INQ==",
       "cpu": [
       "cpu": [
         "s390x"
         "s390x"
       ],
       ],
@@ -3030,9 +3030,9 @@
       }
       }
     },
     },
     "node_modules/@oxlint/binding-linux-x64-gnu": {
     "node_modules/@oxlint/binding-linux-x64-gnu": {
-      "version": "1.82.0",
-      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.82.0.tgz",
-      "integrity": "sha512-XUUUxaBo9XKl+J1B9EmP1cTGQPddzeURvoGkfwh/94PGnbW+hBprDljneoI2M1jzC1bzrIV3ihc7iM9UXl8+tg==",
+      "version": "1.83.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.83.0.tgz",
+      "integrity": "sha512-rS5gM0NgD7ngmuJmbIehsidtrOwKkLFwCQbKEeb9KuyQrrWNq5Zkn0uV6AYdXOMJ0grrWEiLwBuvMxt8w5vsNw==",
       "cpu": [
       "cpu": [
         "x64"
         "x64"
       ],
       ],
@@ -3050,9 +3050,9 @@
       }
       }
     },
     },
     "node_modules/@oxlint/binding-linux-x64-musl": {
     "node_modules/@oxlint/binding-linux-x64-musl": {
-      "version": "1.82.0",
-      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.82.0.tgz",
-      "integrity": "sha512-SWLSFulX9TDuH6yvbPYp4+VNn6jkkIvvI+KiujDM5rWBRHEfkesCC/pCneIIUr6ovkxZ5fRtpi2v5Cz5FrMJZg==",
+      "version": "1.83.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.83.0.tgz",
+      "integrity": "sha512-W2IH4EtpcPaWcvNGCA95YoDg4vxqE/ZiPCi3arrxEEpsK7+JQN9WYwrlYFx9pcdP6KPXqRqkv3zdQPHcx7b6YQ==",
       "cpu": [
       "cpu": [
         "x64"
         "x64"
       ],
       ],
@@ -3070,9 +3070,9 @@
       }
       }
     },
     },
     "node_modules/@oxlint/binding-openharmony-arm64": {
     "node_modules/@oxlint/binding-openharmony-arm64": {
-      "version": "1.82.0",
-      "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.82.0.tgz",
-      "integrity": "sha512-BQy35f6ZUdNr9a6c7B7orxQTcLjByGT2z3WAgmRovpRwmPYAaJ+NTplmMzhdjdJ4qSchfMNZy/Ukg+qRg6zseQ==",
+      "version": "1.83.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.83.0.tgz",
+      "integrity": "sha512-6LyKkUyoajssTPLlZmDbZIbu4IZ5B4bGuRUnBgCGpEvHP3FQMaYITncHA/unPUo7q+Z+pIu2HhdkQ+8d1SG7iA==",
       "cpu": [
       "cpu": [
         "arm64"
         "arm64"
       ],
       ],
@@ -3087,9 +3087,9 @@
       }
       }
     },
     },
     "node_modules/@oxlint/binding-win32-arm64-msvc": {
     "node_modules/@oxlint/binding-win32-arm64-msvc": {
-      "version": "1.82.0",
-      "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.82.0.tgz",
-      "integrity": "sha512-V4QhSTg5gctZue8RJjsGi7NpQPThr/p1/HfmiMC5kfe1KFEup9SQRVub4A6kijQjdHfxj7bLL1KO3QO7/5bwMQ==",
+      "version": "1.83.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.83.0.tgz",
+      "integrity": "sha512-Uz/fObEtF0jmNJQJ8CGRBKfefYstS0/wjD3s6IGzP8nUwsJykHQJBiN3npHwKiGRGn/vvBEgNr4B3cCzmmatvg==",
       "cpu": [
       "cpu": [
         "arm64"
         "arm64"
       ],
       ],
@@ -3104,9 +3104,9 @@
       }
       }
     },
     },
     "node_modules/@oxlint/binding-win32-ia32-msvc": {
     "node_modules/@oxlint/binding-win32-ia32-msvc": {
-      "version": "1.82.0",
-      "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.82.0.tgz",
-      "integrity": "sha512-TUSCLaKB2yktpFAJ/r3HAUYsaV/3DT7JS4iNKyoh3a9YNwD0UG7Ezh4D8m23654vQcU6P/RQrCAjRPKe4peP/A==",
+      "version": "1.83.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.83.0.tgz",
+      "integrity": "sha512-u7XcvPW6Bk58tY5iWs2ESb0vJjoE/kuSpHxopbwp/p3ZtWVQXZ6wor5w3ssVTHOqd/v8b+QdhSFWQ4grEUNWpA==",
       "cpu": [
       "cpu": [
         "ia32"
         "ia32"
       ],
       ],
@@ -3121,9 +3121,9 @@
       }
       }
     },
     },
     "node_modules/@oxlint/binding-win32-x64-msvc": {
     "node_modules/@oxlint/binding-win32-x64-msvc": {
-      "version": "1.82.0",
-      "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.82.0.tgz",
-      "integrity": "sha512-VTVoRIWJTb+wvUX8EYoPArfFH02whuR10goFXE/LHRRX33ajRrFgqbcONXZMiF4C5rnattfkm87HqYn8jb8hmQ==",
+      "version": "1.83.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.83.0.tgz",
+      "integrity": "sha512-LZRubd7ph13QmAg4fFecTYVZkiYbROR2Htaxh/ufWRkDhPOm2wrwaEYR89e0YpPFD3dqBrPoxS7myBw5hmYA7Q==",
       "cpu": [
       "cpu": [
         "x64"
         "x64"
       ],
       ],
@@ -3599,14 +3599,14 @@
       }
       }
     },
     },
     "node_modules/@rc-component/segmented": {
     "node_modules/@rc-component/segmented": {
-      "version": "1.3.0",
-      "resolved": "https://registry.npmjs.org/@rc-component/segmented/-/segmented-1.3.0.tgz",
-      "integrity": "sha512-5J/bJ01mbDnoA6P/FW8SxUvKn+OgUSTZJPzCNnTBntG50tzoP7DydGhqxp7ggZXZls7me3mc2EQDXakU3iTVFg==",
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/@rc-component/segmented/-/segmented-1.4.0.tgz",
+      "integrity": "sha512-NZlpKGSplOYlmF5ukUF1fCLr0ns5WfjNoqMlMkf4AHP0iu90wyiP7hn2qZKRHEuRlG4CvEEz/lF9qRiYnLTofQ==",
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
-        "@babel/runtime": "^7.11.1",
+        "@babel/runtime": "^8.0.0",
         "@rc-component/motion": "^1.1.4",
         "@rc-component/motion": "^1.1.4",
-        "@rc-component/util": "^1.3.0",
+        "@rc-component/util": "^1.11.1",
         "clsx": "^2.1.1"
         "clsx": "^2.1.1"
       },
       },
       "peerDependencies": {
       "peerDependencies": {
@@ -3614,6 +3614,12 @@
         "react-dom": ">=16.0.0"
         "react-dom": ">=16.0.0"
       }
       }
     },
     },
+    "node_modules/@rc-component/segmented/node_modules/@babel/runtime": {
+      "version": "8.0.5",
+      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-8.0.5.tgz",
+      "integrity": "sha512-7NK+Lz3spQ52XsUGTxIEVU4jYN2/dIaX8sTxRFAUtYmttYZnVh3aehiihv+/Gb7+duMNHl2OrFcBQRyOHScxpg==",
+      "license": "MIT"
+    },
     "node_modules/@rc-component/select": {
     "node_modules/@rc-component/select": {
       "version": "1.10.1",
       "version": "1.10.1",
       "resolved": "https://registry.npmjs.org/@rc-component/select/-/select-1.10.1.tgz",
       "resolved": "https://registry.npmjs.org/@rc-component/select/-/select-1.10.1.tgz",
@@ -5158,9 +5164,9 @@
       }
       }
     },
     },
     "node_modules/@testing-library/dom": {
     "node_modules/@testing-library/dom": {
-      "version": "10.4.1",
-      "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
-      "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
+      "version": "10.4.2",
+      "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz",
+      "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==",
       "dev": true,
       "dev": true,
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
@@ -6126,9 +6132,9 @@
       }
       }
     },
     },
     "node_modules/antd": {
     "node_modules/antd": {
-      "version": "6.6.3",
-      "resolved": "https://registry.npmjs.org/antd/-/antd-6.6.3.tgz",
-      "integrity": "sha512-USnOxcOhWEQc65pHapSiCrZOijWef783XnqYZv8fuYIcYVoFxNxl6TsnsUhmoJyZheD/XwAx9CyO+hlyekDCQA==",
+      "version": "6.6.4",
+      "resolved": "https://registry.npmjs.org/antd/-/antd-6.6.4.tgz",
+      "integrity": "sha512-nKFi2oY+KrsfAkU8/KwgS0MPKpryIPTVIgoLQRGiT/BcCMakBf5+G8MPy90RdHjEwb0/bulNJV1dxLhHm9cg7w==",
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
         "@ant-design/colors": "^8.0.1",
         "@ant-design/colors": "^8.0.1",
@@ -6161,7 +6167,7 @@
         "@rc-component/qrcode": "~2.0.0",
         "@rc-component/qrcode": "~2.0.0",
         "@rc-component/rate": "~1.0.1",
         "@rc-component/rate": "~1.0.1",
         "@rc-component/resize-observer": "^1.1.2",
         "@rc-component/resize-observer": "^1.1.2",
-        "@rc-component/segmented": "~1.3.0",
+        "@rc-component/segmented": "~1.4.0",
         "@rc-component/select": "~1.10.1",
         "@rc-component/select": "~1.10.1",
         "@rc-component/slider": "~1.1.1",
         "@rc-component/slider": "~1.1.1",
         "@rc-component/steps": "~1.2.3",
         "@rc-component/steps": "~1.2.3",
@@ -8742,9 +8748,9 @@
       }
       }
     },
     },
     "node_modules/oxfmt": {
     "node_modules/oxfmt": {
-      "version": "0.67.0",
-      "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.67.0.tgz",
-      "integrity": "sha512-vV7sSiPsaO0mSxdoUdayipVDFPzW/UQ+hrezEHa20+Tx1dnMdZLSRHMT0PdS67FFbhd74M1n08asW21aLGeCrA==",
+      "version": "0.68.0",
+      "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.68.0.tgz",
+      "integrity": "sha512-Z0XMofcXCGUXbcpBHnWyUiX93BGiw1B+lcHNbQDWEtOhX06ewoFfu4zXkyiLhRrNnMq0twqXRHUcJetf+GsiQQ==",
       "dev": true,
       "dev": true,
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
@@ -8760,25 +8766,25 @@
         "url": "https://github.com/sponsors/oxc-project"
         "url": "https://github.com/sponsors/oxc-project"
       },
       },
       "optionalDependencies": {
       "optionalDependencies": {
-        "@oxfmt/binding-android-arm-eabi": "0.67.0",
-        "@oxfmt/binding-android-arm64": "0.67.0",
-        "@oxfmt/binding-darwin-arm64": "0.67.0",
-        "@oxfmt/binding-darwin-x64": "0.67.0",
-        "@oxfmt/binding-freebsd-x64": "0.67.0",
-        "@oxfmt/binding-linux-arm-gnueabihf": "0.67.0",
-        "@oxfmt/binding-linux-arm-musleabihf": "0.67.0",
-        "@oxfmt/binding-linux-arm64-gnu": "0.67.0",
-        "@oxfmt/binding-linux-arm64-musl": "0.67.0",
-        "@oxfmt/binding-linux-ppc64-gnu": "0.67.0",
-        "@oxfmt/binding-linux-riscv64-gnu": "0.67.0",
-        "@oxfmt/binding-linux-riscv64-musl": "0.67.0",
-        "@oxfmt/binding-linux-s390x-gnu": "0.67.0",
-        "@oxfmt/binding-linux-x64-gnu": "0.67.0",
-        "@oxfmt/binding-linux-x64-musl": "0.67.0",
-        "@oxfmt/binding-openharmony-arm64": "0.67.0",
-        "@oxfmt/binding-win32-arm64-msvc": "0.67.0",
-        "@oxfmt/binding-win32-ia32-msvc": "0.67.0",
-        "@oxfmt/binding-win32-x64-msvc": "0.67.0"
+        "@oxfmt/binding-android-arm-eabi": "0.68.0",
+        "@oxfmt/binding-android-arm64": "0.68.0",
+        "@oxfmt/binding-darwin-arm64": "0.68.0",
+        "@oxfmt/binding-darwin-x64": "0.68.0",
+        "@oxfmt/binding-freebsd-x64": "0.68.0",
+        "@oxfmt/binding-linux-arm-gnueabihf": "0.68.0",
+        "@oxfmt/binding-linux-arm-musleabihf": "0.68.0",
+        "@oxfmt/binding-linux-arm64-gnu": "0.68.0",
+        "@oxfmt/binding-linux-arm64-musl": "0.68.0",
+        "@oxfmt/binding-linux-ppc64-gnu": "0.68.0",
+        "@oxfmt/binding-linux-riscv64-gnu": "0.68.0",
+        "@oxfmt/binding-linux-riscv64-musl": "0.68.0",
+        "@oxfmt/binding-linux-s390x-gnu": "0.68.0",
+        "@oxfmt/binding-linux-x64-gnu": "0.68.0",
+        "@oxfmt/binding-linux-x64-musl": "0.68.0",
+        "@oxfmt/binding-openharmony-arm64": "0.68.0",
+        "@oxfmt/binding-win32-arm64-msvc": "0.68.0",
+        "@oxfmt/binding-win32-ia32-msvc": "0.68.0",
+        "@oxfmt/binding-win32-x64-msvc": "0.68.0"
       },
       },
       "peerDependencies": {
       "peerDependencies": {
         "svelte": "^5.0.0",
         "svelte": "^5.0.0",
@@ -8794,9 +8800,9 @@
       }
       }
     },
     },
     "node_modules/oxlint": {
     "node_modules/oxlint": {
-      "version": "1.82.0",
-      "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.82.0.tgz",
-      "integrity": "sha512-+iFM1BGw1ntYJt3QngbJmjbrGxPaKMUADOXOijpWGnYcBPq8YZnQftSS1C+pVcDYy9YxqDVJKQqQkTazTQMboQ==",
+      "version": "1.83.0",
+      "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.83.0.tgz",
+      "integrity": "sha512-cyDzSzaw3uzP0TeCeq3lLRPPoaUxkbB4ZOXj+kn+5r+BX9V+4bNVGk9lxer+WrgcpebH4JxLlJ3KQjveVztOLQ==",
       "dev": true,
       "dev": true,
       "license": "MIT",
       "license": "MIT",
       "bin": {
       "bin": {
@@ -8809,25 +8815,25 @@
         "url": "https://github.com/sponsors/oxc-project"
         "url": "https://github.com/sponsors/oxc-project"
       },
       },
       "optionalDependencies": {
       "optionalDependencies": {
-        "@oxlint/binding-android-arm-eabi": "1.82.0",
-        "@oxlint/binding-android-arm64": "1.82.0",
-        "@oxlint/binding-darwin-arm64": "1.82.0",
-        "@oxlint/binding-darwin-x64": "1.82.0",
-        "@oxlint/binding-freebsd-x64": "1.82.0",
-        "@oxlint/binding-linux-arm-gnueabihf": "1.82.0",
-        "@oxlint/binding-linux-arm-musleabihf": "1.82.0",
-        "@oxlint/binding-linux-arm64-gnu": "1.82.0",
-        "@oxlint/binding-linux-arm64-musl": "1.82.0",
-        "@oxlint/binding-linux-ppc64-gnu": "1.82.0",
-        "@oxlint/binding-linux-riscv64-gnu": "1.82.0",
-        "@oxlint/binding-linux-riscv64-musl": "1.82.0",
-        "@oxlint/binding-linux-s390x-gnu": "1.82.0",
-        "@oxlint/binding-linux-x64-gnu": "1.82.0",
-        "@oxlint/binding-linux-x64-musl": "1.82.0",
-        "@oxlint/binding-openharmony-arm64": "1.82.0",
-        "@oxlint/binding-win32-arm64-msvc": "1.82.0",
-        "@oxlint/binding-win32-ia32-msvc": "1.82.0",
-        "@oxlint/binding-win32-x64-msvc": "1.82.0"
+        "@oxlint/binding-android-arm-eabi": "1.83.0",
+        "@oxlint/binding-android-arm64": "1.83.0",
+        "@oxlint/binding-darwin-arm64": "1.83.0",
+        "@oxlint/binding-darwin-x64": "1.83.0",
+        "@oxlint/binding-freebsd-x64": "1.83.0",
+        "@oxlint/binding-linux-arm-gnueabihf": "1.83.0",
+        "@oxlint/binding-linux-arm-musleabihf": "1.83.0",
+        "@oxlint/binding-linux-arm64-gnu": "1.83.0",
+        "@oxlint/binding-linux-arm64-musl": "1.83.0",
+        "@oxlint/binding-linux-ppc64-gnu": "1.83.0",
+        "@oxlint/binding-linux-riscv64-gnu": "1.83.0",
+        "@oxlint/binding-linux-riscv64-musl": "1.83.0",
+        "@oxlint/binding-linux-s390x-gnu": "1.83.0",
+        "@oxlint/binding-linux-x64-gnu": "1.83.0",
+        "@oxlint/binding-linux-x64-musl": "1.83.0",
+        "@oxlint/binding-openharmony-arm64": "1.83.0",
+        "@oxlint/binding-win32-arm64-msvc": "1.83.0",
+        "@oxlint/binding-win32-ia32-msvc": "1.83.0",
+        "@oxlint/binding-win32-x64-msvc": "1.83.0"
       },
       },
       "peerDependencies": {
       "peerDependencies": {
         "oxlint-tsgolint": ">=7.0.2001",
         "oxlint-tsgolint": ">=7.0.2001",
@@ -9279,9 +9285,9 @@
       }
       }
     },
     },
     "node_modules/react-i18next": {
     "node_modules/react-i18next": {
-      "version": "17.0.13",
-      "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.13.tgz",
-      "integrity": "sha512-Cc1PscmblIHA1kljTqDwrcVMI21ydgmUzw0UAeQBe7pAOgfuRLfzXze4EUBQoeDiICzFIXXhHFoZxuetNg5D0Q==",
+      "version": "17.0.14",
+      "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.14.tgz",
+      "integrity": "sha512-ZpMBfJL3BiXPuYHj5QMY1GwvKJNhE1vCxOJQ8BoMBdHP5XONTfi/Qss1nuRlnFdIl72PKo2jPmw+fxuLjRFgaQ==",
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
         "@babel/runtime": "^7.29.7",
         "@babel/runtime": "^7.29.7",
@@ -11033,9 +11039,9 @@
       "license": "Unlicense"
       "license": "Unlicense"
     },
     },
     "node_modules/zod": {
     "node_modules/zod": {
-      "version": "4.6.2",
-      "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.2.tgz",
-      "integrity": "sha512-lh5RCAGFa1Cm2hjtNwLQhSs/AsqdWnTQaBER9fEwN/88pSh7KOtJavtBx/0VlkN/uFd61SwYmljLMDAsHlvzBQ==",
+      "version": "4.6.5",
+      "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz",
+      "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==",
       "license": "MIT",
       "license": "MIT",
       "funding": {
       "funding": {
         "url": "https://github.com/sponsors/colinhacks"
         "url": "https://github.com/sponsors/colinhacks"

+ 6 - 6
frontend/package.json

@@ -41,7 +41,7 @@
     "@noble/hashes": "^2.4.0",
     "@noble/hashes": "^2.4.0",
     "@tanstack/react-query": "^5.102.8",
     "@tanstack/react-query": "^5.102.8",
     "@tanstack/react-query-devtools": "^5.102.8",
     "@tanstack/react-query-devtools": "^5.102.8",
-    "antd": "^6.6.3",
+    "antd": "^6.6.4",
     "codemirror": "^6.0.2",
     "codemirror": "^6.0.2",
     "dayjs": "^1.11.23",
     "dayjs": "^1.11.23",
     "i18next": "^26.4.2",
     "i18next": "^26.4.2",
@@ -50,18 +50,18 @@
     "react": "^19.3.0",
     "react": "^19.3.0",
     "react-dom": "^19.3.0",
     "react-dom": "^19.3.0",
     "react-hook-form": "^7.88.0",
     "react-hook-form": "^7.88.0",
-    "react-i18next": "^17.0.13",
+    "react-i18next": "^17.0.14",
     "react-router": "^8.3.1",
     "react-router": "^8.3.1",
     "swagger-ui-react": "^5.32.15",
     "swagger-ui-react": "^5.32.15",
     "uplot": "^1.6.32",
     "uplot": "^1.6.32",
-    "zod": "^4.6.2"
+    "zod": "^4.6.5"
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@storybook/addon-a11y": "^10.6.0",
     "@storybook/addon-a11y": "^10.6.0",
     "@storybook/addon-docs": "^10.6.0",
     "@storybook/addon-docs": "^10.6.0",
     "@storybook/addon-vitest": "^10.6.0",
     "@storybook/addon-vitest": "^10.6.0",
     "@storybook/react-vite": "^10.6.0",
     "@storybook/react-vite": "^10.6.0",
-    "@testing-library/dom": "^10.4.1",
+    "@testing-library/dom": "^10.4.2",
     "@testing-library/react": "^16.3.3",
     "@testing-library/react": "^16.3.3",
     "@types/react": "^19.3.0",
     "@types/react": "^19.3.0",
     "@types/react-dom": "^19.3.0",
     "@types/react-dom": "^19.3.0",
@@ -73,8 +73,8 @@
     "jsdom": "^30.0.1",
     "jsdom": "^30.0.1",
     "lint-staged": "^17.5.1",
     "lint-staged": "^17.5.1",
     "msw": "^2.15.0",
     "msw": "^2.15.0",
-    "oxfmt": "0.67.0",
-    "oxlint": "1.82.0",
+    "oxfmt": "0.68.0",
+    "oxlint": "1.83.0",
     "oxlint-tsgolint": "^7.0.2001",
     "oxlint-tsgolint": "^7.0.2001",
     "playwright": "^1.63.0",
     "playwright": "^1.63.0",
     "storybook": "^10.6.0",
     "storybook": "^10.6.0",

+ 3 - 2
frontend/src/api/queries/useOutboundTags.ts

@@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query';
 
 
 import { keys } from '@/api/queryKeys';
 import { keys } from '@/api/queryKeys';
 import { fetchXrayConfig } from '@/hooks/useXraySetting';
 import { fetchXrayConfig } from '@/hooks/useXraySetting';
+import { isOutboundProtocol } from '@/schemas/primitives';
 
 
 // Available outbound (and balancer-eligible) tags the user can route an mtproto
 // Available outbound (and balancer-eligible) tags the user can route an mtproto
 // inbound's Telegram traffic to. Shares the cached xray config query so opening
 // inbound's Telegram traffic to. Shares the cached xray config query so opening
@@ -18,7 +19,7 @@ export function useOutboundTags(opts?: { excludeBlackhole?: boolean }) {
       for (const o of data?.xraySetting?.outbounds ?? []) {
       for (const o of data?.xraySetting?.outbounds ?? []) {
         const ob = o as { tag?: string; protocol?: string } | null;
         const ob = o as { tag?: string; protocol?: string } | null;
         if (!ob?.tag) continue;
         if (!ob?.tag) continue;
-        if (excludeBlackhole && ob.protocol === 'blackhole') continue;
+        if (excludeBlackhole && isOutboundProtocol(ob, 'blackhole')) continue;
         tags.add(ob.tag);
         tags.add(ob.tag);
       }
       }
       for (const t of data?.subscriptionOutboundTags ?? []) {
       for (const t of data?.subscriptionOutboundTags ?? []) {
@@ -56,7 +57,7 @@ export function useOutboundTagGroups(opts?: { excludeBlackhole?: boolean }) {
       for (const o of data?.xraySetting?.outbounds ?? []) {
       for (const o of data?.xraySetting?.outbounds ?? []) {
         const ob = o as { tag?: string; protocol?: string } | null;
         const ob = o as { tag?: string; protocol?: string } | null;
         if (!ob?.tag) continue;
         if (!ob?.tag) continue;
-        if (excludeBlackhole && ob.protocol === 'blackhole') continue;
+        if (excludeBlackhole && isOutboundProtocol(ob, 'blackhole')) continue;
         outbounds.add(ob.tag);
         outbounds.add(ob.tag);
       }
       }
       for (const t of data?.subscriptionOutboundTags ?? []) {
       for (const t of data?.subscriptionOutboundTags ?? []) {

+ 22 - 12
frontend/src/hooks/useXraySetting.ts

@@ -5,6 +5,7 @@ import { z } from 'zod';
 import { HttpUtil, Msg } from '@/utils';
 import { HttpUtil, Msg } from '@/utils';
 import { parseMsg } from '@/utils/zodValidate';
 import { parseMsg } from '@/utils/zodValidate';
 import { keys } from '@/api/queryKeys';
 import { keys } from '@/api/queryKeys';
+import { isOutboundProtocol } from '@/schemas/primitives';
 import {
 import {
   OutboundTrafficListSchema,
   OutboundTrafficListSchema,
   OutboundTestResultListSchema,
   OutboundTestResultListSchema,
@@ -25,20 +26,24 @@ function normalizeOutboundTestUrl(url: string) {
   return url || DEFAULT_TEST_URL;
   return url || DEFAULT_TEST_URL;
 }
 }
 
 
+// The core lowercases a protocol id and a transport name before resolving
+// either, so "WireGuard"/"KCP" still build a UDP handler a TCP dial misreports.
 export function isUdpOutbound(outbound: unknown): boolean {
 export function isUdpOutbound(outbound: unknown): boolean {
   const o = outbound as
   const o = outbound as
-    | { protocol?: string; streamSettings?: { network?: string } }
+    | { protocol?: unknown; streamSettings?: { network?: unknown } }
     | null
     | null
     | undefined;
     | undefined;
-  const p = o?.protocol;
-  const n = o?.streamSettings?.network;
+  const rawNetwork = o?.streamSettings?.network;
+  const network = typeof rawNetwork === 'string' ? rawNetwork.toLowerCase() : '';
   return (
   return (
-    p === 'wireguard' ||
-    p === 'hysteria' ||
-    p === 'amneziawg' ||
-    n === 'hysteria' ||
-    n === 'kcp' ||
-    n === 'quic'
+    isOutboundProtocol(o, 'wireguard') ||
+    isOutboundProtocol(o, 'hysteria') ||
+    isOutboundProtocol(o, 'amneziawg') ||
+    network === 'hysteria' ||
+    network === 'kcp' ||
+    // The core resolves "kcp" and "mkcp" to the same mKCP transport.
+    network === 'mkcp' ||
+    network === 'quic'
   );
   );
 }
 }
 
 
@@ -386,10 +391,15 @@ export function useXraySetting(): UseXraySettingResult {
           index: number,
           index: number,
           tag: string,
           tag: string,
         ) => {
         ) => {
-          const proto = ob?.protocol;
-          if (proto === 'blackhole' || proto === 'loopback' || ob?.tag === 'blocked') return;
+          if (
+            isOutboundProtocol(ob, 'blackhole') ||
+            isOutboundProtocol(ob, 'loopback') ||
+            ob?.tag === 'blocked'
+          ) {
+            return;
+          }
           // freedom ("direct") and dns aren't proxies — skip them in every mode.
           // freedom ("direct") and dns aren't proxies — skip them in every mode.
-          if (proto === 'freedom' || proto === 'dns') return;
+          if (isOutboundProtocol(ob, 'freedom') || isOutboundProtocol(ob, 'dns')) return;
           if (kind === 'sub' && !tag) return;
           if (kind === 'sub' && !tag) return;
           const toHttp = mode !== 'tcp' || isUdpOutbound(ob);
           const toHttp = mode !== 'tcp' || isUdpOutbound(ob);
           if (kind === 'tpl') {
           if (kind === 'tpl') {

+ 3 - 1
frontend/src/lib/xray/outbound-form-adapter.ts

@@ -541,7 +541,9 @@ function hydrateStreamForm(stream: Raw): OutboundStreamFormValues {
 }
 }
 
 
 export function rawOutboundToFormValues(raw: RawOutboundRow): OutboundFormValues {
 export function rawOutboundToFormValues(raw: RawOutboundRow): OutboundFormValues {
-  const protocol = asString(raw.protocol, 'vless');
+  // The core lowercases a protocol id before it looks the handler up, so a
+  // template pasted as "Freedom" must not fall through to the vless default.
+  const protocol = asString(raw.protocol, 'vless').toLowerCase();
   const settings = asObject(raw.settings);
   const settings = asObject(raw.settings);
   const tag = asString(raw.tag);
   const tag = asString(raw.tag);
   const sendThrough = asString(raw.sendThrough);
   const sendThrough = asString(raw.sendThrough);

+ 2 - 1
frontend/src/pages/inbounds/form/protocols/mtproto.tsx

@@ -11,7 +11,7 @@ export default function MtprotoFields() {
   const routeThroughXray = useWatch({ control, name: 'settings.routeThroughXray' }) as
   const routeThroughXray = useWatch({ control, name: 'settings.routeThroughXray' }) as
     | boolean
     | boolean
     | undefined;
     | undefined;
-  const { data: outboundTags } = useOutboundTags();
+  const { data: outboundTags } = useOutboundTags({ excludeBlackhole: true });
   return (
   return (
     <>
     <>
       <FormField
       <FormField
@@ -89,6 +89,7 @@ export default function MtprotoFields() {
           tooltip={t('pages.inbounds.form.mtgRouteOutboundHint')}
           tooltip={t('pages.inbounds.form.mtgRouteOutboundHint')}
         >
         >
           <Select
           <Select
+            id="mtprotoOutboundTag"
             allowClear
             allowClear
             showSearch
             showSearch
             placeholder={t('pages.inbounds.form.mtgRouteOutboundPlaceholder')}
             placeholder={t('pages.inbounds.form.mtgRouteOutboundPlaceholder')}

+ 2 - 1
frontend/src/pages/index/GeodataSection.tsx

@@ -4,6 +4,7 @@ import { Alert, Button, Form, Input, Modal, Select, Space, Spin, Typography, mes
 import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
 import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
 
 
 import { XrayConfigPayloadSchema } from '@/schemas/xray';
 import { XrayConfigPayloadSchema } from '@/schemas/xray';
+import { isOutboundProtocol } from '@/schemas/primitives';
 import { HttpUtil } from '@/utils';
 import { HttpUtil } from '@/utils';
 
 
 interface GeodataAssetRow {
 interface GeodataAssetRow {
@@ -75,7 +76,7 @@ export default function GeodataSection({ active, onBusy, onClose }: GeodataSecti
       for (const o of outbounds) {
       for (const o of outbounds) {
         if (!o || typeof o !== 'object') continue;
         if (!o || typeof o !== 'object') continue;
         const rec = o as Record<string, unknown>;
         const rec = o as Record<string, unknown>;
-        if (rec.protocol === 'blackhole') continue;
+        if (isOutboundProtocol(rec, 'blackhole')) continue;
         const tag = rec.tag;
         const tag = rec.tag;
         if (typeof tag === 'string' && tag) tags.add(tag);
         if (typeof tag === 'string' && tag) tags.add(tag);
       }
       }

+ 2 - 1
frontend/src/pages/settings/GeneralTab.tsx

@@ -10,6 +10,7 @@ import {
   SettingOutlined,
   SettingOutlined,
 } from '@ant-design/icons';
 } from '@ant-design/icons';
 import type { AllSetting } from '@/models/setting';
 import type { AllSetting } from '@/models/setting';
+import { isOutboundProtocol } from '@/schemas/primitives';
 import { HttpUtil, LanguageManager } from '@/utils';
 import { HttpUtil, LanguageManager } from '@/utils';
 import { onNumber } from '@/utils/onNumber';
 import { onNumber } from '@/utils/onNumber';
 import { DefaultSettingTag, SettingListItem } from '@/components/ui';
 import { DefaultSettingTag, SettingListItem } from '@/components/ui';
@@ -86,7 +87,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
         for (const o of outbounds) {
         for (const o of outbounds) {
           if (!o || typeof o !== 'object') continue;
           if (!o || typeof o !== 'object') continue;
           const rec = o as Record<string, unknown>;
           const rec = o as Record<string, unknown>;
-          if (rec.protocol === 'blackhole') continue; // dropping traffic is never a useful egress
+          if (isOutboundProtocol(rec, 'blackhole')) continue; // never a useful egress
           const tag = rec.tag;
           const tag = rec.tag;
           if (typeof tag === 'string' && tag) tags.add(tag);
           if (typeof tag === 'string' && tag) tags.add(tag);
         }
         }

+ 14 - 10
frontend/src/pages/xray/basics/BasicsTab.tsx

@@ -25,7 +25,13 @@ import {
   MASK_ADDRESS,
   MASK_ADDRESS,
   ROUTING_DOMAIN_STRATEGIES,
   ROUTING_DOMAIN_STRATEGIES,
 } from './constants';
 } from './constants';
-import { directFreedomStrategy, setDirectFreedomStrategy } from './helpers';
+import {
+  directFreedomStrategy,
+  ensureDirectFreedomOutbound,
+  isDirectFreedomOutbound,
+  isDirectTagTaken,
+  setDirectFreedomStrategy,
+} from './helpers';
 
 
 interface BasicsTabProps {
 interface BasicsTabProps {
   templateSettings: XraySettingsValue | null;
   templateSettings: XraySettingsValue | null;
@@ -112,9 +118,10 @@ export default function BasicsTab({
 
 
   const freedomStrategy = directFreedomStrategy(templateSettings);
   const freedomStrategy = directFreedomStrategy(templateSettings);
 
 
-  const directFreedomOutbound = templateSettings?.outbounds?.find(
-    (o) => o?.protocol === 'freedom' && o?.tag === 'direct',
+  const directFreedomOutbound = templateSettings?.outbounds?.find((o) =>
+    isDirectFreedomOutbound(o),
   );
   );
+  const directTagTaken = isDirectTagTaken(templateSettings);
   const directHappyEyeballs = (() => {
   const directHappyEyeballs = (() => {
     const sockopt = (
     const sockopt = (
       directFreedomOutbound?.streamSettings as { sockopt?: { happyEyeballs?: unknown } } | undefined
       directFreedomOutbound?.streamSettings as { sockopt?: { happyEyeballs?: unknown } } | undefined
@@ -128,13 +135,8 @@ export default function BasicsTab({
   const setDirectHappyEyeballs = useCallback(
   const setDirectHappyEyeballs = useCallback(
     (next: ReturnType<typeof HappyEyeballsSchema.parse> | null) => {
     (next: ReturnType<typeof HappyEyeballsSchema.parse> | null) => {
       mutate((tt) => {
       mutate((tt) => {
-        if (!tt.outbounds) tt.outbounds = [];
-        let idx = tt.outbounds.findIndex((o) => o?.protocol === 'freedom' && o?.tag === 'direct');
-        if (idx < 0) {
-          tt.outbounds.push({ protocol: 'freedom', tag: 'direct', settings: {} });
-          idx = tt.outbounds.length - 1;
-        }
-        const ob = tt.outbounds[idx];
+        const ob = ensureDirectFreedomOutbound(tt);
+        if (!ob) return;
         const stream = (ob.streamSettings ?? {}) as Record<string, unknown>;
         const stream = (ob.streamSettings ?? {}) as Record<string, unknown>;
         const sockopt = (stream.sockopt ?? {}) as Record<string, unknown>;
         const sockopt = (stream.sockopt ?? {}) as Record<string, unknown>;
         if (next == null) {
         if (next == null) {
@@ -181,6 +183,7 @@ export default function BasicsTab({
             control={
             control={
               <Select
               <Select
                 value={freedomStrategy}
                 value={freedomStrategy}
+                disabled={directTagTaken}
                 style={{ width: '100%' }}
                 style={{ width: '100%' }}
                 options={OutboundDomainStrategies.map((s) => ({ value: s, label: s }))}
                 options={OutboundDomainStrategies.map((s) => ({ value: s, label: s }))}
                 onChange={(next) => mutate((tt) => setDirectFreedomStrategy(tt, next))}
                 onChange={(next) => mutate((tt) => setDirectFreedomStrategy(tt, next))}
@@ -194,6 +197,7 @@ export default function BasicsTab({
             control={
             control={
               <Switch
               <Switch
                 checked={directHappyEyeballs != null}
                 checked={directHappyEyeballs != null}
+                disabled={directTagTaken}
                 onChange={(checked) => {
                 onChange={(checked) => {
                   setDirectHappyEyeballs(checked ? HappyEyeballsSchema.parse({}) : null);
                   setDirectHappyEyeballs(checked ? HappyEyeballsSchema.parse({}) : null);
                 }}
                 }}

+ 28 - 10
frontend/src/pages/xray/basics/helpers.ts

@@ -8,10 +8,17 @@ const LEGACY_FREEDOM_STRATEGY_KEYS = ['domainStrategy', 'targetStrategy'] as con
 
 
 type Outbound = Record<string, unknown>;
 type Outbound = Record<string, unknown>;
 
 
+// The core lowercases a protocol id before it resolves the handler, so matching
+// it exactly would append a second "direct" the core refuses to load.
+export function isDirectFreedomOutbound(o: Outbound | undefined): boolean {
+  const protocol = o?.protocol;
+  return (
+    typeof protocol === 'string' && protocol.toLowerCase() === 'freedom' && o?.tag === 'direct'
+  );
+}
+
 function directFreedom(t: XraySettingsValue | null): Outbound | undefined {
 function directFreedom(t: XraySettingsValue | null): Outbound | undefined {
-  return t?.outbounds?.find((o) => o?.protocol === 'freedom' && o?.tag === 'direct') as
-    | Outbound
-    | undefined;
+  return t?.outbounds?.find((o) => isDirectFreedomOutbound(o)) as Outbound | undefined;
 }
 }
 
 
 export function directFreedomStrategy(t: XraySettingsValue | null): string {
 export function directFreedomStrategy(t: XraySettingsValue | null): string {
@@ -20,14 +27,25 @@ export function directFreedomStrategy(t: XraySettingsValue | null): string {
   return freedomDomainStrategyFromWire(outbound) || 'AsIs';
   return freedomDomainStrategyFromWire(outbound) || 'AsIs';
 }
 }
 
 
-export function setDirectFreedomStrategy(t: XraySettingsValue, next: string): void {
+// The core refuses to load two outbounds sharing a tag, so a "direct" held by
+// a non-freedom egress keeps it and the Basics controls have nothing to edit.
+export function isDirectTagTaken(t: XraySettingsValue | null): boolean {
+  return !directFreedom(t) && !!t?.outbounds?.some((o) => o?.tag === 'direct');
+}
+
+export function ensureDirectFreedomOutbound(t: XraySettingsValue): Outbound | undefined {
   if (!Array.isArray(t.outbounds)) t.outbounds = [];
   if (!Array.isArray(t.outbounds)) t.outbounds = [];
-  let idx = t.outbounds.findIndex((o) => o?.protocol === 'freedom' && o?.tag === 'direct');
-  if (idx < 0) {
-    t.outbounds.push({ protocol: 'freedom', tag: 'direct', settings: {} } as never);
-    idx = t.outbounds.length - 1;
-  }
-  const ob = t.outbounds[idx] as Outbound;
+  const found = directFreedom(t);
+  if (found) return found;
+  if (isDirectTagTaken(t)) return undefined;
+  const created: Outbound = { protocol: 'freedom', tag: 'direct', settings: {} };
+  t.outbounds.push(created as never);
+  return created;
+}
+
+export function setDirectFreedomStrategy(t: XraySettingsValue, next: string): void {
+  const ob = ensureDirectFreedomOutbound(t);
+  if (!ob) return;
   // Drop the legacy placements, or the loader keeps warning and the core keeps
   // Drop the legacy placements, or the loader keeps warning and the core keeps
   // preferring the root key it resets over the sockopt value set here.
   // preferring the root key it resets over the sockopt value set here.
   const settings = (ob.settings ?? {}) as Outbound;
   const settings = (ob.settings ?? {}) as Outbound;

+ 2 - 1
frontend/src/pages/xray/outbounds/OutboundsTab.tsx

@@ -37,6 +37,7 @@ import {
   ImportOutlined,
   ImportOutlined,
 } from '@ant-design/icons';
 } from '@ant-design/icons';
 
 
+import { isOutboundProtocol } from '@/schemas/primitives';
 import { HttpUtil } from '@/utils';
 import { HttpUtil } from '@/utils';
 import { onNumber } from '@/utils/onNumber';
 import { onNumber } from '@/utils/onNumber';
 import PromptModal from '@/components/feedback/PromptModal';
 import PromptModal from '@/components/feedback/PromptModal';
@@ -183,7 +184,7 @@ export default function OutboundsTab({
     const tags = new Set<string>();
     const tags = new Set<string>();
     (templateSettings?.outbounds || []).forEach((o, i) => {
     (templateSettings?.outbounds || []).forEach((o, i) => {
       if (i === editingIndex) return;
       if (i === editingIndex) return;
-      if (o?.protocol === 'blackhole') return;
+      if (isOutboundProtocol(o, 'blackhole')) return;
       if (o?.tag) tags.add(o.tag);
       if (o?.tag) tags.add(o.tag);
     });
     });
     for (const tag of subscriptionOutboundTags || []) {
     for (const tag of subscriptionOutboundTags || []) {

+ 21 - 14
frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts

@@ -1,6 +1,6 @@
 import type { TFunction } from 'i18next';
 import type { TFunction } from 'i18next';
 
 
-import { OutboundProtocols as Protocols } from '@/schemas/primitives';
+import { isOutboundProtocol, OutboundProtocols as Protocols } from '@/schemas/primitives';
 import { isUdpOutbound } from '@/hooks/useXraySetting';
 import { isUdpOutbound } from '@/hooks/useXraySetting';
 import type {
 import type {
   OutboundTestMode,
   OutboundTestMode,
@@ -25,27 +25,34 @@ export function originalOutboundIndex(rows: OutboundRow[], positionalIndex: numb
 
 
 export function outboundAddresses(o: OutboundRow): string[] {
 export function outboundAddresses(o: OutboundRow): string[] {
   const settings = o.settings as Record<string, unknown> | undefined;
   const settings = o.settings as Record<string, unknown> | undefined;
-  switch (o.protocol) {
-    case Protocols.VMess: {
+  switch (true) {
+    case isOutboundProtocol(o, Protocols.VMess): {
       const serverObj = settings?.vnext as Array<{ address: string; port: number }> | undefined;
       const serverObj = settings?.vnext as Array<{ address: string; port: number }> | undefined;
       return serverObj ? serverObj.map((s) => `${s.address}:${s.port}`) : [];
       return serverObj ? serverObj.map((s) => `${s.address}:${s.port}`) : [];
     }
     }
-    case Protocols.VLESS:
-      return [`${settings?.address || ''}:${settings?.port || ''}`];
-    case Protocols.HTTP:
-    case Protocols.Socks:
-    case Protocols.Shadowsocks:
-    case Protocols.Trojan: {
+    case isOutboundProtocol(o, Protocols.VLESS):
+    case isOutboundProtocol(o, Protocols.Hysteria): {
+      // A vless row carries either shape, and the probe reads both.
+      const vnext = settings?.vnext as Array<{ address?: string; port?: number }> | undefined;
+      const addr = vnext?.[0]?.address || (settings?.address as string | undefined);
+      const port = vnext?.[0]?.port || (settings?.port as string | number | undefined);
+      return addr || port ? [`${addr || ''}:${port || ''}`] : [];
+    }
+    case isOutboundProtocol(o, Protocols.HTTP):
+    case isOutboundProtocol(o, Protocols.Socks):
+    case isOutboundProtocol(o, Protocols.Shadowsocks):
+    case isOutboundProtocol(o, Protocols.Trojan): {
       const serverObj = settings?.servers as Array<{ address: string; port: number }> | undefined;
       const serverObj = settings?.servers as Array<{ address: string; port: number }> | undefined;
       return serverObj ? serverObj.map((s) => `${s.address}:${s.port}`) : [];
       return serverObj ? serverObj.map((s) => `${s.address}:${s.port}`) : [];
     }
     }
-    case Protocols.DNS: {
+    case isOutboundProtocol(o, Protocols.DNS): {
       const addr = (settings?.rewriteAddress as string) || (settings?.address as string) || '';
       const addr = (settings?.rewriteAddress as string) || (settings?.address as string) || '';
       const port =
       const port =
         (settings?.rewritePort as string | number) || (settings?.port as string | number) || '';
         (settings?.rewritePort as string | number) || (settings?.port as string | number) || '';
       return addr || port ? [`${addr}:${port}`] : [];
       return addr || port ? [`${addr}:${port}`] : [];
     }
     }
-    case Protocols.Wireguard:
+    case isOutboundProtocol(o, Protocols.Wireguard):
+    case isOutboundProtocol(o, Protocols.AmneziaWG):
       return ((settings?.peers as Array<{ endpoint?: string }>) || [])
       return ((settings?.peers as Array<{ endpoint?: string }>) || [])
         .map((p) => p.endpoint || '')
         .map((p) => p.endpoint || '')
         .filter(Boolean);
         .filter(Boolean);
@@ -57,15 +64,15 @@ export function outboundAddresses(o: OutboundRow): string[] {
 export function isUntestable(o: OutboundRow): boolean {
 export function isUntestable(o: OutboundRow): boolean {
   if (!o) return true;
   if (!o) return true;
   if (
   if (
-    o.protocol === Protocols.Blackhole ||
-    o.protocol === Protocols.Loopback ||
+    isOutboundProtocol(o, Protocols.Blackhole) ||
+    isOutboundProtocol(o, Protocols.Loopback) ||
     o.tag === 'blocked'
     o.tag === 'blocked'
   )
   )
     return true;
     return true;
   // freedom ("direct") and dns aren't proxies — a TCP dial has no endpoint and
   // freedom ("direct") and dns aren't proxies — a TCP dial has no endpoint and
   // an HTTP probe would only measure the host's own direct reachability, so
   // an HTTP probe would only measure the host's own direct reachability, so
   // they're untestable in every mode.
   // they're untestable in every mode.
-  if (o.protocol === Protocols.Freedom || o.protocol === Protocols.DNS) return true;
+  if (isOutboundProtocol(o, Protocols.Freedom) || isOutboundProtocol(o, Protocols.DNS)) return true;
   return false;
   return false;
 }
 }
 
 

+ 3 - 3
frontend/src/pages/xray/outbounds/useOutboundColumns.tsx

@@ -18,7 +18,7 @@ import type { ColumnsType } from 'antd/es/table';
 
 
 import { SizeFormatter } from '@/utils';
 import { SizeFormatter } from '@/utils';
 import { activateOnKey } from '@/utils/a11y';
 import { activateOnKey } from '@/utils/a11y';
-import { OutboundProtocols as Protocols } from '@/schemas/primitives';
+import { isOutboundProtocol, OutboundProtocols as Protocols } from '@/schemas/primitives';
 import type {
 import type {
   OutboundTestMode,
   OutboundTestMode,
   OutboundTestState,
   OutboundTestState,
@@ -169,8 +169,8 @@ export function useOutboundColumns({
             </Tooltip>
             </Tooltip>
             <div className="protocol-line">
             <div className="protocol-line">
               <Tag color="green">{record.protocol}</Tag>
               <Tag color="green">{record.protocol}</Tag>
-              {[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(
-                record.protocol as never,
+              {[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].some(
+                (id) => isOutboundProtocol(record, id),
               ) && (
               ) && (
                 <>
                 <>
                   <Tag>{record.streamSettings?.network}</Tag>
                   <Tag>{record.streamSettings?.network}</Tag>

+ 10 - 0
frontend/src/schemas/primitives/outbound-protocol.ts

@@ -1,3 +1,13 @@
+// xray-core lowercases a protocol id before it resolves the handler, so a
+// template pasted as "Freedom" still runs as the freedom outbound.
+export function isOutboundProtocol(
+  outbound: { protocol?: unknown } | null | undefined,
+  id: string,
+): boolean {
+  const protocol = outbound?.protocol;
+  return typeof protocol === 'string' && protocol.toLowerCase() === id;
+}
+
 export const OutboundProtocols = Object.freeze({
 export const OutboundProtocols = Object.freeze({
   Freedom: 'freedom',
   Freedom: 'freedom',
   Blackhole: 'blackhole',
   Blackhole: 'blackhole',

+ 35 - 0
frontend/src/test/basics-freedom-strategy.test.ts

@@ -48,6 +48,41 @@ describe('BasicsTab freedom strategy', () => {
     });
     });
   });
   });
 
 
+  it('finds the direct outbound when the template spells it "Freedom"', () => {
+    const t = {
+      outbounds: [
+        {
+          protocol: 'Freedom',
+          tag: 'direct',
+          settings: {},
+          streamSettings: { sockopt: { domainStrategy: 'UseIPv6' } },
+        },
+      ],
+    } as unknown as XraySettingsValue;
+
+    expect(directFreedomStrategy(t)).toBe('UseIPv6');
+
+    setDirectFreedomStrategy(t, 'UseIPv4');
+
+    expect(t.outbounds).toHaveLength(1);
+    expect(directOutbound(t).streamSettings).toEqual({ sockopt: { domainStrategy: 'UseIPv4' } });
+  });
+
+  it('never leaves a taken "direct" tag on two outbounds', () => {
+    const t = {
+      outbounds: [{ protocol: 'socks', tag: 'direct', settings: { servers: [] } }],
+    } as unknown as XraySettingsValue;
+
+    setDirectFreedomStrategy(t, 'UseIPv4');
+
+    expect(t.outbounds).toHaveLength(1);
+    expect(directOutbound(t)).toEqual({
+      protocol: 'socks',
+      tag: 'direct',
+      settings: { servers: [] },
+    });
+  });
+
   it('keeps other sockopt keys the transport form already set', () => {
   it('keeps other sockopt keys the transport form already set', () => {
     const t = settingsWithDirect({}, { sockopt: { tcpFastOpen: true } });
     const t = settingsWithDirect({}, { sockopt: { tcpFastOpen: true } });
 
 

+ 44 - 0
frontend/src/test/basics-tab-direct-tag.test.tsx

@@ -0,0 +1,44 @@
+import { screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+import BasicsTab from '@/pages/xray/basics/BasicsTab';
+import type { XraySettingsValue } from '@/hooks/useXraySetting';
+import { renderWithProviders } from './test-utils';
+
+function renderBasics(outbounds: Record<string, unknown>[]) {
+  renderWithProviders(
+    <BasicsTab
+      templateSettings={{ outbounds } as unknown as XraySettingsValue}
+      setTemplateSettings={vi.fn()}
+      outboundTestUrl=""
+      onChangeOutboundTestUrl={vi.fn()}
+      onResetDefault={vi.fn()}
+    />,
+  );
+  return {
+    strategy: screen.getByRole('combobox', { name: 'Freedom Protocol Strategy' }),
+    happyEyeballs: screen.getByRole('switch', { name: 'Freedom Happy Eyeballs (IPv4/IPv6)' }),
+  };
+}
+
+// Both setters drop the edit when a non-freedom outbound holds "direct", so
+// the controls must say so instead of snapping back silently.
+describe('BasicsTab with the direct tag held by a foreign outbound', () => {
+  it('disables the freedom controls', () => {
+    const { strategy, happyEyeballs } = renderBasics([
+      { protocol: 'socks', tag: 'direct', settings: { servers: [] } },
+    ]);
+
+    expect(strategy).toHaveProperty('disabled', true);
+    expect(happyEyeballs).toHaveProperty('disabled', true);
+  });
+
+  it('keeps them enabled when freedom holds the tag, whatever its spelling', () => {
+    const { strategy, happyEyeballs } = renderBasics([
+      { protocol: 'Freedom', tag: 'direct', settings: {} },
+    ]);
+
+    expect(strategy).toHaveProperty('disabled', false);
+    expect(happyEyeballs).toHaveProperty('disabled', false);
+  });
+});

+ 58 - 0
frontend/src/test/mtproto-egress-picker.test.tsx

@@ -0,0 +1,58 @@
+import type { ReactNode } from 'react';
+import { Form } from 'antd';
+import { waitFor } from '@testing-library/react';
+import { FormProvider, useForm } from 'react-hook-form';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import MtprotoFields from '@/pages/inbounds/form/protocols/mtproto';
+import { HttpUtil, Msg } from '@/utils';
+import { listSelectOptions, renderWithProviders } from './test-utils';
+
+afterEach(() => {
+  vi.restoreAllMocks();
+});
+
+// The picker exists so Telegram traffic can be sent through a proxy; offering
+// the block outbound there looks like a working selection and drops the traffic.
+function mockConfigWithBlockOutbound() {
+  const payload = {
+    xraySetting: {
+      outbounds: [
+        { tag: 'direct', protocol: 'freedom' },
+        { tag: 'blocked', protocol: 'blackhole' },
+        { tag: 'warp', protocol: 'wireguard' },
+      ],
+    },
+  };
+  vi.spyOn(HttpUtil, 'post').mockResolvedValue(new Msg(true, '', JSON.stringify(payload)));
+}
+
+function Harness({ children }: { children: ReactNode }) {
+  const methods = useForm({ defaultValues: { settings: { routeThroughXray: true } } });
+  return (
+    <FormProvider {...methods}>
+      <Form>{children}</Form>
+    </FormProvider>
+  );
+}
+
+// The field carries an explicit id (like the inbound form's protocol select), so
+// the assertions can't drift onto another select that happens to be nearby.
+const EGRESS_FIELD = 'mtprotoOutboundTag';
+
+describe('mtproto egress picker', () => {
+  it('offers the routable tags and not the block outbound', async () => {
+    mockConfigWithBlockOutbound();
+    renderWithProviders(
+      <Harness>
+        <MtprotoFields />
+      </Harness>,
+    );
+
+    await waitFor(() => expect(listSelectOptions(EGRESS_FIELD)).toContain('direct'));
+
+    const options = listSelectOptions(EGRESS_FIELD);
+    expect(options).toContain('warp');
+    expect(options).not.toContain('blocked');
+  });
+});

+ 26 - 0
frontend/src/test/outbound-form-adapter.test.ts

@@ -535,6 +535,32 @@ describe('outbound-form-adapter: round-trip', () => {
     const form = rawOutboundToFormValues({ protocol: 'mysterious', settings: {} });
     const form = rawOutboundToFormValues({ protocol: 'mysterious', settings: {} });
     expect(form.protocol).toBe('vless');
     expect(form.protocol).toBe('vless');
   });
   });
+
+  it('reads a protocol id the way the core does, whatever its case', () => {
+    const freedom = rawOutboundToFormValues({
+      protocol: 'Freedom',
+      tag: 'direct',
+      settings: { redirect: '1.1.1.1' },
+      streamSettings: { sockopt: { domainStrategy: 'UseIPv4' } },
+    });
+    expect(freedom.protocol).toBe('freedom');
+    if (freedom.protocol === 'freedom') {
+      expect(freedom.settings.redirect).toBe('1.1.1.1');
+    }
+    const back = formValuesToWirePayload(freedom);
+    expect(back.protocol).toBe('freedom');
+    expect(back.tag).toBe('direct');
+    expect((back.settings as Record<string, unknown>).redirect).toBe('1.1.1.1');
+
+    const vless = rawOutboundToFormValues({
+      protocol: 'VLESS',
+      settings: { address: 'srv', port: 443, id: '11111111-2222-4333-8444-555555555555' },
+    });
+    expect(vless.protocol).toBe('vless');
+    if (vless.protocol === 'vless') {
+      expect(vless.settings.address).toBe('srv');
+    }
+  });
 });
 });
 
 
 describe('outbound-form-adapter: targetStrategy', () => {
 describe('outbound-form-adapter: targetStrategy', () => {

+ 60 - 0
frontend/src/test/outbound-identity-tags-case.test.tsx

@@ -0,0 +1,60 @@
+import { describe, it, expect, vi } from 'vitest';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+
+import OutboundsTab from '@/pages/xray/outbounds/OutboundsTab';
+import type { XraySettingsValue } from '@/hooks/useXraySetting';
+
+import { renderWithProviders } from './test-utils';
+
+// The core lowercases the id, so a "VMess" row is a vmess outbound: its stream
+// tags must follow the same rule its address does.
+function settingsWithCapitalisedProtocol(): XraySettingsValue {
+  return {
+    outbounds: [
+      {
+        tag: 'proxy-a',
+        protocol: 'VMess',
+        settings: { vnext: [{ address: 'a.example.com', port: 443 }] },
+        streamSettings: { network: 'ws', security: 'tls' },
+      },
+    ],
+  } as unknown as XraySettingsValue;
+}
+
+function renderTab(settings: XraySettingsValue) {
+  const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+  return renderWithProviders(
+    <QueryClientProvider client={queryClient}>
+      <OutboundsTab
+        templateSettings={settings}
+        setTemplateSettings={vi.fn()}
+        outboundsTraffic={[]}
+        outboundTestStates={{}}
+        subscriptionTestStates={{}}
+        testingAll={false}
+        inboundTags={[]}
+        isMobile={false}
+        onResetTraffic={vi.fn()}
+        onTest={vi.fn()}
+        onTestSubscription={vi.fn()}
+        onTestAll={vi.fn()}
+        onShowWarp={vi.fn()}
+        onShowNord={vi.fn()}
+        onShowPia={vi.fn()}
+      />
+    </QueryClientProvider>,
+  );
+}
+
+describe('OutboundsTab row for a case-variant protocol id', () => {
+  it('renders the stream tags and the address of a "VMess" row', () => {
+    renderTab(settingsWithCapitalisedProtocol());
+
+    const row = document.querySelector('.ant-table-tbody tr.ant-table-row');
+    const tags = Array.from(row?.querySelectorAll('.protocol-line .ant-tag') ?? []).map(
+      (el) => el.textContent,
+    );
+    expect(tags).toEqual(['VMess', 'ws', 'tls']);
+    expect(row?.textContent).toContain('a.example.com:443');
+  });
+});

+ 28 - 0
frontend/src/test/outbound-protocol-case.test.ts

@@ -0,0 +1,28 @@
+import { describe, expect, it } from 'vitest';
+
+import { isOutboundProtocol } from '@/schemas/primitives';
+
+// xray-core lowercases a protocol id in LoadWithID before it resolves the
+// handler, so every panel reader has to accept the spellings it accepts.
+describe('isOutboundProtocol', () => {
+  it.each([
+    ['canonical', { protocol: 'freedom' }],
+    ['capitalised', { protocol: 'Freedom' }],
+    ['upper', { protocol: 'FREEDOM' }],
+    ['mixed', { protocol: 'fReEdOm' }],
+  ])('matches a %s id', (_name, outbound) => {
+    expect(isOutboundProtocol(outbound, 'freedom')).toBe(true);
+  });
+
+  it.each([
+    ['another protocol', { protocol: 'blackhole' }],
+    ['another spelling of another protocol', { protocol: 'Blackhole' }],
+    ['missing', {}],
+    ['empty', { protocol: '' }],
+    ['not a string', { protocol: 42 }],
+    ['null', null],
+    ['undefined', undefined],
+  ])('rejects %s', (_name, outbound) => {
+    expect(isOutboundProtocol(outbound, 'freedom')).toBe(false);
+  });
+});

+ 73 - 0
frontend/src/test/outbounds-addresses-case.test.ts

@@ -0,0 +1,73 @@
+import { describe, expect, it } from 'vitest';
+
+import { outboundAddresses } from '@/pages/xray/outbounds/outbounds-tab-helpers';
+import type { OutboundRow } from '@/pages/xray/outbounds/outbounds-tab-types';
+
+// The core lowercases a protocol id before resolving the handler, so a row
+// spelled "VMess" must still show the address its settings carry.
+const row = (protocol: string, settings: Record<string, unknown>): OutboundRow => ({
+  key: 0,
+  tag: 'p',
+  protocol,
+  settings,
+});
+
+const vnext = { vnext: [{ address: 'a.example.com', port: 443 }] };
+
+describe('outboundAddresses', () => {
+  it('reads a capitalised vmess id', () => {
+    expect(outboundAddresses(row('VMess', vnext))).toEqual(['a.example.com:443']);
+  });
+
+  it('reads a capitalised trojan id', () => {
+    expect(
+      outboundAddresses(row('Trojan', { servers: [{ address: 'b.example.com', port: 8443 }] })),
+    ).toEqual(['b.example.com:8443']);
+  });
+
+  it('reads a capitalised wireguard id', () => {
+    expect(
+      outboundAddresses(row('WireGuard', { peers: [{ endpoint: 'c.example.com:51820' }] })),
+    ).toEqual(['c.example.com:51820']);
+  });
+
+  it('reads a capitalised dns id', () => {
+    expect(outboundAddresses(row('DNS', { rewriteAddress: '1.1.1.1', rewritePort: 53 }))).toEqual([
+      '1.1.1.1:53',
+    ]);
+  });
+
+  it('reads the flat server of a capitalised vless id', () => {
+    expect(outboundAddresses(row('VLESS', { address: 'd.example.com', port: 443 }))).toEqual([
+      'd.example.com:443',
+    ]);
+  });
+
+  it('leaves a canonical id unchanged', () => {
+    expect(outboundAddresses(row('vmess', vnext))).toEqual(['a.example.com:443']);
+  });
+
+  it('still returns nothing for a protocol that carries no address', () => {
+    expect(outboundAddresses(row('freedom', {}))).toEqual([]);
+  });
+
+  it('reads the vnext server of a vless row', () => {
+    expect(outboundAddresses(row('VLESS', vnext))).toEqual(['a.example.com:443']);
+  });
+
+  it('returns no bare separator for a vless row that carries no server', () => {
+    expect(outboundAddresses(row('VLESS', {}))).toEqual([]);
+  });
+
+  it('reads the flat server of a hysteria id', () => {
+    expect(outboundAddresses(row('hysteria', { address: 'e.example.com', port: 443 }))).toEqual([
+      'e.example.com:443',
+    ]);
+  });
+
+  it('reads the peer endpoint of an amneziawg id', () => {
+    expect(
+      outboundAddresses(row('amneziawg', { peers: [{ endpoint: 'f.example.com:51820' }] })),
+    ).toEqual(['f.example.com:51820']);
+  });
+});

+ 26 - 0
frontend/src/test/outbounds-testability-gate.test.ts

@@ -0,0 +1,26 @@
+import { describe, expect, it } from 'vitest';
+
+import { isUntestable } from '@/pages/xray/outbounds/outbounds-tab-helpers';
+import type { OutboundRow } from '@/pages/xray/outbounds/outbounds-tab-types';
+
+// The row's Test button is disabled by this gate alone: a spelling the core
+// resolves but the gate misses reports the panel host's own reachability as a tunnel.
+const row = (protocol: string, tag = 'probe'): OutboundRow => ({ key: 0, tag, protocol });
+
+describe('isUntestable', () => {
+  it.each(['Freedom', 'FREEDOM', 'fReEdOm'])('disables a %s row', (protocol) => {
+    expect(isUntestable(row(protocol))).toBe(true);
+  });
+
+  it.each(['DNS', 'Dns'])('disables a %s row', (protocol) => {
+    expect(isUntestable(row(protocol))).toBe(true);
+  });
+
+  it.each(['Blackhole', 'BLACKHOLE', 'Loopback'])('disables a %s row', (protocol) => {
+    expect(isUntestable(row(protocol))).toBe(true);
+  });
+
+  it.each(['vless', 'VMess', 'Trojan', 'Socks'])('leaves a %s row testable', (protocol) => {
+    expect(isUntestable(row(protocol))).toBe(false);
+  });
+});

+ 67 - 0
frontend/src/test/use-outbound-tags.test.tsx

@@ -0,0 +1,67 @@
+import type { ReactNode } from 'react';
+import { renderHook, waitFor } from '@testing-library/react';
+import { QueryClientProvider } from '@tanstack/react-query';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { useOutboundTagGroups, useOutboundTags } from '@/api/queries/useOutboundTags';
+import { makeTestQueryClient } from '@/test/test-utils';
+import { HttpUtil, Msg } from '@/utils';
+
+afterEach(() => {
+  vi.restoreAllMocks();
+});
+
+// The core lowercases a protocol id before it resolves the handler, so a
+// template that spells the block outbound "Blackhole" still drops traffic.
+function mockConfig() {
+  const payload = {
+    xraySetting: {
+      outbounds: [
+        { tag: 'direct', protocol: 'freedom' },
+        { tag: 'blocked', protocol: 'Blackhole' },
+        { tag: 'warp', protocol: 'wireguard' },
+      ],
+    },
+  };
+  vi.spyOn(HttpUtil, 'post').mockResolvedValue(new Msg(true, '', JSON.stringify(payload)));
+}
+
+function wrapperFor() {
+  const queryClient = makeTestQueryClient();
+  return ({ children }: { children: ReactNode }) => (
+    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
+  );
+}
+
+describe('outbound tag pickers', () => {
+  it('excludes a block outbound whose id is spelled differently', async () => {
+    mockConfig();
+
+    const { result } = renderHook(() => useOutboundTags({ excludeBlackhole: true }), {
+      wrapper: wrapperFor(),
+    });
+
+    await waitFor(() => expect(result.current.data).toBeDefined());
+    expect(result.current.data).toEqual(['direct', 'warp']);
+  });
+
+  it('keeps the same tag in the grouped picker out of its outbound list', async () => {
+    mockConfig();
+
+    const { result } = renderHook(() => useOutboundTagGroups({ excludeBlackhole: true }), {
+      wrapper: wrapperFor(),
+    });
+
+    await waitFor(() => expect(result.current.data).toBeDefined());
+    expect(result.current.data?.outbounds).toEqual(['direct', 'warp']);
+  });
+
+  it('offers every tag when the caller does not exclude blocks', async () => {
+    mockConfig();
+
+    const { result } = renderHook(() => useOutboundTags(), { wrapper: wrapperFor() });
+
+    await waitFor(() => expect(result.current.data).toBeDefined());
+    expect(result.current.data).toEqual(['direct', 'blocked', 'warp']);
+  });
+});

+ 45 - 0
frontend/src/test/use-xray-setting.test.tsx

@@ -67,4 +67,49 @@ describe('useXraySetting', () => {
     expect(result.current.outboundTestUrl).toBe('');
     expect(result.current.outboundTestUrl).toBe('');
     expect(result.current.saveDisabled).toBe(true);
     expect(result.current.saveDisabled).toBe(true);
   });
   });
+
+  // The core lowercases a protocol id and a transport name before resolving
+  // either, so a differently spelled UDP outbound must still skip the TCP dial.
+  it.each<[string, Record<string, unknown>, string]>([
+    ['probes a canonical UDP outbound over HTTP', { protocol: 'wireguard', tag: 'wg' }, 'http'],
+    [
+      'probes a "WireGuard"-spelled outbound over HTTP',
+      { protocol: 'WireGuard', tag: 'wg' },
+      'http',
+    ],
+    ['probes a "HyStErIa"-spelled outbound over HTTP', { protocol: 'HyStErIa', tag: 'hy' }, 'http'],
+    [
+      'probes a "KCP" transport over HTTP',
+      { protocol: 'vless', tag: 'kcp', streamSettings: { network: 'KCP' } },
+      'http',
+    ],
+    [
+      'probes an "mkcp" transport over HTTP',
+      { protocol: 'vless', tag: 'mkcp', streamSettings: { network: 'mkcp' } },
+      'http',
+    ],
+    ['probes a plain vless outbound over TCP', { protocol: 'vless', tag: 'plain' }, 'tcp'],
+  ])('%s', async (_name, outbound, want) => {
+    const bodies: Array<Record<string, unknown>> = [];
+    vi.spyOn(HttpUtil, 'post').mockImplementation(async (url, data) => {
+      if (url === '/panel/api/xray/') {
+        return new Msg(true, '', JSON.stringify(xrayPayload()));
+      }
+      bodies.push(data as Record<string, unknown>);
+      return new Msg(true, '', [{ success: true, mode: 'http' }]);
+    });
+    const queryClient = makeTestQueryClient();
+    const wrapper = ({ children }: { children: ReactNode }) => (
+      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
+    );
+    const { result } = renderHook(() => useXraySetting(), { wrapper });
+
+    await waitFor(() => expect(result.current.fetched).toBe(true));
+    await act(async () => {
+      await result.current.testOutbound(0, outbound, 'tcp');
+    });
+
+    expect(bodies).toHaveLength(1);
+    expect(bodies[0].mode).toBe(want);
+  });
 });
 });

+ 3 - 3
go.mod

@@ -34,7 +34,7 @@ require (
 	google.golang.org/grpc v1.83.2
 	google.golang.org/grpc v1.83.2
 	google.golang.org/protobuf v1.36.12
 	google.golang.org/protobuf v1.36.12
 	gopkg.in/natefinch/lumberjack.v2 v2.2.1
 	gopkg.in/natefinch/lumberjack.v2 v2.2.1
-	gorm.io/driver/postgres v1.6.2
+	gorm.io/driver/postgres v1.6.3
 	gorm.io/driver/sqlite v1.6.0
 	gorm.io/driver/sqlite v1.6.0
 	gorm.io/gorm v1.31.2
 	gorm.io/gorm v1.31.2
 	gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0
 	gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0
@@ -87,13 +87,13 @@ require (
 	github.com/pion/dtls/v3 v3.1.8 // indirect
 	github.com/pion/dtls/v3 v3.1.8 // indirect
 	github.com/pion/logging v0.2.4 // indirect
 	github.com/pion/logging v0.2.4 // indirect
 	github.com/pion/stun/v3 v3.1.7 // indirect
 	github.com/pion/stun/v3 v3.1.7 // indirect
-	github.com/pion/transport/v4 v4.1.0 // indirect
+	github.com/pion/transport/v4 v4.1.1 // indirect
 	github.com/pires/go-proxyproto v0.15.0 // indirect
 	github.com/pires/go-proxyproto v0.15.0 // indirect
 	github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 // indirect
 	github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 // indirect
 	github.com/quic-go/qpack v0.6.0 // indirect
 	github.com/quic-go/qpack v0.6.0 // indirect
 	github.com/quic-go/quic-go v0.62.0 // indirect
 	github.com/quic-go/quic-go v0.62.0 // indirect
 	github.com/rogpeppe/go-internal v1.15.0 // indirect
 	github.com/rogpeppe/go-internal v1.15.0 // indirect
-	github.com/sagernet/sing v0.9.3 // indirect
+	github.com/sagernet/sing v0.9.4 // indirect
 	github.com/sagernet/sing-shadowsocks v0.2.9 // indirect
 	github.com/sagernet/sing-shadowsocks v0.2.9 // indirect
 	github.com/tklauser/go-sysconf v0.4.0 // indirect
 	github.com/tklauser/go-sysconf v0.4.0 // indirect
 	github.com/tklauser/numcpus v0.12.0 // indirect
 	github.com/tklauser/numcpus v0.12.0 // indirect

+ 6 - 6
go.sum

@@ -168,8 +168,8 @@ github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
 github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
 github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
 github.com/pion/stun/v3 v3.1.7 h1:uRXMTlGLf89WgItGNyZ6aR5jMTX0NBbybXADpQCzn+E=
 github.com/pion/stun/v3 v3.1.7 h1:uRXMTlGLf89WgItGNyZ6aR5jMTX0NBbybXADpQCzn+E=
 github.com/pion/stun/v3 v3.1.7/go.mod h1:Nq77RW4aRrSNrltf2ksUJLjxWeipj4lnlgdsYIxC8g8=
 github.com/pion/stun/v3 v3.1.7/go.mod h1:Nq77RW4aRrSNrltf2ksUJLjxWeipj4lnlgdsYIxC8g8=
-github.com/pion/transport/v4 v4.1.0 h1:8S+nF2reM2cJuqC6g78OVy2BBgmbdns+acx3jA97BvQ=
-github.com/pion/transport/v4 v4.1.0/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
+github.com/pion/transport/v4 v4.1.1 h1:kEBtbp1sRad0tnkREDNJ3fmop/CR8p/YxXnpFbn9tqo=
+github.com/pion/transport/v4 v4.1.1/go.mod h1:Oxo5oODQvjFF7NkHBBz3f4fZXLB7x03uLVuzfYeA5pE=
 github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI=
 github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI=
 github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24=
 github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -187,8 +187,8 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
 github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
 github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
 github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
 github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
 github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
 github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
-github.com/sagernet/sing v0.9.3 h1:66QWoMcD24KrQ4PqiaNNWApogJaf6LoozcW2RPtJ7KE=
-github.com/sagernet/sing v0.9.3/go.mod h1:K3Owt3xPhHugvlnlPPxZJ/exXdaJfEPOTNorGk4AXjo=
+github.com/sagernet/sing v0.9.4 h1:nIdH+Yvy4NzTezB+c3z/9of1jLRggnrtMtb3uMOIaNM=
+github.com/sagernet/sing v0.9.4/go.mod h1:K3Owt3xPhHugvlnlPPxZJ/exXdaJfEPOTNorGk4AXjo=
 github.com/sagernet/sing-shadowsocks v0.2.9 h1:Paep5zCszRKsEn8587O0MnhFWKJwDW1Y4zOYYlIxMkM=
 github.com/sagernet/sing-shadowsocks v0.2.9 h1:Paep5zCszRKsEn8587O0MnhFWKJwDW1Y4zOYYlIxMkM=
 github.com/sagernet/sing-shadowsocks v0.2.9/go.mod h1:TE/Z6401Pi8tgr0nBZcM/xawAI6u3F6TTbz4nH/qw+8=
 github.com/sagernet/sing-shadowsocks v0.2.9/go.mod h1:TE/Z6401Pi8tgr0nBZcM/xawAI6u3F6TTbz4nH/qw+8=
 github.com/shirou/gopsutil/v4 v4.26.8 h1:YQMTF/1J50B5+Y0vlo1eDRf5DoR7Gk69hY+8wjYkQeo=
 github.com/shirou/gopsutil/v4 v4.26.8 h1:YQMTF/1J50B5+Y0vlo1eDRf5DoR7Gk69hY+8wjYkQeo=
@@ -306,8 +306,8 @@ gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gorm.io/driver/postgres v1.6.2 h1:BvXQ/cNUg63q5TFNg672DmDcowZSFrNLkkA3Xe6GXq4=
-gorm.io/driver/postgres v1.6.2/go.mod h1:0c4fQA44XhOklXDkgtuKqysHCycTa5i9e3EIpDGCwXk=
+gorm.io/driver/postgres v1.6.3 h1:bAn6O2pUa8LtpWEvL5NFU4+52Tfx8Ut7IVaIacCLcI0=
+gorm.io/driver/postgres v1.6.3/go.mod h1:0c4fQA44XhOklXDkgtuKqysHCycTa5i9e3EIpDGCwXk=
 gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
 gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
 gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
 gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
 gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
 gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=

+ 1 - 1
internal/config/version

@@ -1 +1 @@
-3.7.0
+3.8.0

+ 82 - 5
internal/database/db.go

@@ -1254,7 +1254,7 @@ func runSeeders(isUsersEmpty bool) error {
 	}
 	}
 
 
 	if empty && isUsersEmpty {
 	if empty && isUsersEmpty {
-		seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "OutboundRemovedKeysFix", "FreedomDomainStrategyFix", "DNSOutboundLegacyKeysFix", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "ResetIpLimitNoFail2ban"}
+		seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "UppercaseFreedomFinalRulesFix", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "OutboundRemovedKeysFix", "FreedomDomainStrategyFix", "DNSOutboundLegacyKeysFix", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "ResetIpLimitNoFail2ban"}
 		for _, name := range seeders {
 		for _, name := range seeders {
 			if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
 			if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
 				return err
 				return err
@@ -1347,6 +1347,12 @@ func runSeeders(isUsersEmpty bool) error {
 		}
 		}
 	}
 	}
 
 
+	if !slices.Contains(seedersHistory, "UppercaseFreedomFinalRulesFix") {
+		if err := fixUppercaseFreedomFinalRules(); err != nil {
+			return err
+		}
+	}
+
 	if !slices.Contains(seedersHistory, "InboundRealityFinalmaskTcpStrip") {
 	if !slices.Contains(seedersHistory, "InboundRealityFinalmaskTcpStrip") {
 		if err := stripRealityFinalmaskTcp(); err != nil {
 		if err := stripRealityFinalmaskTcp(); err != nil {
 			return err
 			return err
@@ -1625,7 +1631,7 @@ func rewriteRemovedOutboundKeys(raw string) (string, bool, error) {
 			delete(obj, "proxySettings")
 			delete(obj, "proxySettings")
 			changed = true
 			changed = true
 		}
 		}
-		if proto, _ := obj["protocol"].(string); proto == "freedom" {
+		if proto, _ := obj["protocol"].(string); strings.EqualFold(proto, "freedom") {
 			if sockopt := outboundSockopt(obj, false); sockopt != nil {
 			if sockopt := outboundSockopt(obj, false); sockopt != nil {
 				if _, present := sockopt["addressPortStrategy"]; present {
 				if _, present := sockopt["addressPortStrategy"]; present {
 					delete(sockopt, "addressPortStrategy")
 					delete(sockopt, "addressPortStrategy")
@@ -1711,7 +1717,7 @@ func rewriteFreedomDomainStrategy(raw string) (string, bool, error) {
 		if !ok {
 		if !ok {
 			continue
 			continue
 		}
 		}
-		if proto, _ := obj["protocol"].(string); proto != "freedom" {
+		if proto, _ := obj["protocol"].(string); !strings.EqualFold(proto, "freedom") {
 			continue
 			continue
 		}
 		}
 		settings, hasSettings := obj["settings"].(map[string]any)
 		settings, hasSettings := obj["settings"].(map[string]any)
@@ -2144,7 +2150,7 @@ func rewriteFreedomFinalRules(raw string) (string, bool, error) {
 		if !ok {
 		if !ok {
 			continue
 			continue
 		}
 		}
-		if proto, _ := obj["protocol"].(string); proto != "freedom" {
+		if proto, _ := obj["protocol"].(string); !strings.EqualFold(proto, "freedom") {
 			continue
 			continue
 		}
 		}
 		settings, ok := obj["settings"].(map[string]any)
 		settings, ok := obj["settings"].(map[string]any)
@@ -2247,7 +2253,7 @@ func rewriteFreedomFinalRulesPrivateEgress(raw string) (string, bool, error) {
 		if !ok {
 		if !ok {
 			continue
 			continue
 		}
 		}
-		if proto, _ := obj["protocol"].(string); proto != "freedom" {
+		if proto, _ := obj["protocol"].(string); !strings.EqualFold(proto, "freedom") {
 			continue
 			continue
 		}
 		}
 		settings, ok := obj["settings"].(map[string]any)
 		settings, ok := obj["settings"].(map[string]any)
@@ -2276,6 +2282,77 @@ func rewriteFreedomFinalRulesPrivateEgress(raw string) (string, bool, error) {
 	return string(out), true, nil
 	return string(out), true, nil
 }
 }
 
 
+func fixUppercaseFreedomFinalRules() error {
+	var setting model.Setting
+	err := db.Model(model.Setting{}).Where("key = ?", "xrayTemplateConfig").First(&setting).Error
+	if errors.Is(err, gorm.ErrRecordNotFound) {
+		return db.Create(&model.HistoryOfSeeders{SeederName: "UppercaseFreedomFinalRulesFix"}).Error
+	}
+	if err != nil {
+		return err
+	}
+
+	updated, changed, rErr := rewriteUppercaseFreedomFinalRules(setting.Value)
+	if rErr != nil {
+		log.Printf("UppercaseFreedomFinalRulesFix: skip (invalid xrayTemplateConfig json): %v", rErr)
+		return db.Create(&model.HistoryOfSeeders{SeederName: "UppercaseFreedomFinalRulesFix"}).Error
+	}
+
+	return db.Transaction(func(tx *gorm.DB) error {
+		if changed {
+			if err := tx.Model(&model.Setting{}).Where("key = ?", "xrayTemplateConfig").
+				Update("value", updated).Error; err != nil {
+				return err
+			}
+		}
+		return tx.Create(&model.HistoryOfSeeders{SeederName: "UppercaseFreedomFinalRulesFix"}).Error
+	})
+}
+
+// Re-runs both finalRules rewrites, because the rows of the two seeders above it
+// already exist on any panel that walked past a differently spelled outbound.
+func rewriteUppercaseFreedomFinalRules(raw string) (string, bool, error) {
+	if !hasNonLowercaseFreedomOutbound(raw) {
+		return raw, false, nil
+	}
+	reversed, reversedChanged, err := rewriteFreedomFinalRules(raw)
+	if err != nil {
+		return raw, false, err
+	}
+	hardened, hardenedChanged, err := rewriteFreedomFinalRulesPrivateEgress(reversed)
+	if err != nil {
+		return raw, false, err
+	}
+	if !reversedChanged && !hardenedChanged {
+		return raw, false, nil
+	}
+	return hardened, true, nil
+}
+
+func hasNonLowercaseFreedomOutbound(raw string) bool {
+	if strings.TrimSpace(raw) == "" {
+		return false
+	}
+	var cfg map[string]any
+	if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
+		return false
+	}
+	outbounds, ok := cfg["outbounds"].([]any)
+	if !ok {
+		return false
+	}
+	for _, ob := range outbounds {
+		obj, ok := ob.(map[string]any)
+		if !ok {
+			continue
+		}
+		if proto, _ := obj["protocol"].(string); strings.EqualFold(proto, "freedom") && proto != "freedom" {
+			return true
+		}
+	}
+	return false
+}
+
 func stripRealityFinalmaskTcp() error {
 func stripRealityFinalmaskTcp() error {
 	var inbounds []model.Inbound
 	var inbounds []model.Inbound
 	if err := db.Find(&inbounds).Error; err != nil {
 	if err := db.Find(&inbounds).Error; err != nil {

+ 5 - 0
internal/database/freedom_domain_strategy_migration_test.go

@@ -169,6 +169,11 @@ func TestRewriteFreedomDomainStrategySatisfiesCore(t *testing.T) {
 			raw:       `{"protocol":"freedom","tag":"direct","targetStrategy":"ForceIPv6","settings":{}}`,
 			raw:       `{"protocol":"freedom","tag":"direct","targetStrategy":"ForceIPv6","settings":{}}`,
 			wantValue: `"domainStrategy": "ForceIPv6"`,
 			wantValue: `"domainStrategy": "ForceIPv6"`,
 		},
 		},
+		{
+			name:      "uppercase protocol spelling",
+			raw:       `{"protocol":"Freedom","tag":"direct","settings":{"domainStrategy":"UseIPv4","finalRules":[{"action":"allow"}]}}`,
+			wantValue: `"domainStrategy": "UseIPv4"`,
+		},
 	} {
 	} {
 		t.Run(tc.name, func(t *testing.T) {
 		t.Run(tc.name, func(t *testing.T) {
 			capture := captureCoreLogs(t)
 			capture := captureCoreLogs(t)

+ 249 - 0
internal/database/rewriter_protocol_case_test.go

@@ -0,0 +1,249 @@
+package database
+
+import (
+	"encoding/json"
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/config"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+func TestRewriteRemovedOutboundKeysSeesAnUppercaseFreedom(t *testing.T) {
+	raw := `{"outbounds":[{"protocol":"Freedom","tag":"direct","settings":{},"streamSettings":{"sockopt":{"addressPortStrategy":"SrvPortOnly"}}}]}`
+	var before struct {
+		Outbounds []json.RawMessage `json:"outbounds"`
+	}
+	if err := json.Unmarshal([]byte(raw), &before); err != nil {
+		t.Fatal(err)
+	}
+	// A refusal here is the proof the core treated it as freedom, not as unknown.
+	if err := xray.ValidateOutboundConfig(before.Outbounds[0]); err == nil {
+		t.Fatal("expected the vendored core to refuse the legacy addressPortStrategy")
+	}
+
+	updated, changed, err := rewriteRemovedOutboundKeys(raw)
+	if err != nil {
+		t.Fatalf("rewrite: %v", err)
+	}
+	if !changed {
+		t.Fatal(`an outbound spelled "Freedom" was left with the key the core refuses`)
+	}
+	var after struct {
+		Outbounds []json.RawMessage `json:"outbounds"`
+	}
+	if err := json.Unmarshal([]byte(updated), &after); err != nil {
+		t.Fatal(err)
+	}
+	if err := xray.ValidateOutboundConfig(after.Outbounds[0]); err != nil {
+		t.Fatalf("rewritten outbound still refused by xray-core: %v", err)
+	}
+}
+
+func TestRewriteUppercaseFreedomFinalRules(t *testing.T) {
+	hardened := []any{
+		map[string]any{"action": "block", "ip": []any{"geoip:private"}},
+		map[string]any{"action": "allow"},
+	}
+	tests := []struct {
+		name        string
+		raw         string
+		wantChanged bool
+		wantRules   []any
+	}{
+		{
+			name:        "stock allow-only rules are hardened",
+			raw:         `{"outbounds":[{"protocol":"Freedom","tag":"direct","settings":{"finalRules":[{"action":"allow"}]}}]}`,
+			wantChanged: true,
+			wantRules:   hardened,
+		},
+		{
+			name:        "legacy private-only allow is hardened",
+			raw:         `{"outbounds":[{"protocol":"FREEDOM","tag":"direct","settings":{"finalRules":[{"action":"allow","ip":["geoip:private"]}]}}]}`,
+			wantChanged: true,
+			wantRules:   hardened,
+		},
+		{
+			name:        "missing finalRules is hardened",
+			raw:         `{"outbounds":[{"protocol":"Freedom","tag":"direct","settings":{}}]}`,
+			wantChanged: true,
+			wantRules:   hardened,
+		},
+		{
+			name:        "customized rules are preserved",
+			raw:         `{"outbounds":[{"protocol":"Freedom","tag":"direct","settings":{"finalRules":[{"action":"block","ip":["1.2.3.4"]},{"action":"allow"}]}}]}`,
+			wantChanged: false,
+		},
+		{
+			name:        "a canonical spelling was already handled by its own seeder",
+			raw:         `{"outbounds":[{"protocol":"freedom","tag":"direct","settings":{"finalRules":[{"action":"allow"}]}}]}`,
+			wantChanged: false,
+		},
+		{
+			name:        "another protocol is ignored",
+			raw:         `{"outbounds":[{"protocol":"blackhole","tag":"blocked","settings":{}}]}`,
+			wantChanged: false,
+		},
+	}
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			updated, changed, err := rewriteUppercaseFreedomFinalRules(tc.raw)
+			if err != nil {
+				t.Fatalf("rewrite: %v", err)
+			}
+			if changed != tc.wantChanged {
+				t.Fatalf("changed = %v, want %v (out: %s)", changed, tc.wantChanged, updated)
+			}
+			if !tc.wantChanged {
+				if updated != tc.raw {
+					t.Fatalf("raw config mutated without change flag:\n%s", updated)
+				}
+				return
+			}
+			var cfg map[string]any
+			if err := json.Unmarshal([]byte(updated), &cfg); err != nil {
+				t.Fatalf("updated config is not valid json: %v", err)
+			}
+			outbounds := cfg["outbounds"].([]any)
+			settings := outbounds[0].(map[string]any)["settings"].(map[string]any)
+			gotRules, _ := json.Marshal(settings["finalRules"])
+			wantRules, _ := json.Marshal(tc.wantRules)
+			if string(gotRules) != string(wantRules) {
+				t.Fatalf("finalRules = %s, want %s", gotRules, wantRules)
+			}
+		})
+	}
+}
+
+// The two earlier seeders recorded their rows before this predicate existed, so
+// this pins the re-run reaching a panel whose history already has both.
+func TestUppercaseFreedomFinalRulesFixReachesHistoryGatedPanels(t *testing.T) {
+	t.Setenv("XUI_DB_FOLDER", t.TempDir())
+	if err := InitDB(config.GetDBPath()); err != nil {
+		if strings.Contains(err.Error(), "CGO_ENABLED=0") {
+			t.Skipf("sqlite needs cgo: %v", err)
+		}
+		t.Fatalf("init db: %v", err)
+	}
+	t.Cleanup(func() { _ = CloseDB() })
+
+	stock := `{"outbounds":[{"protocol":"Freedom","tag":"direct","settings":{"finalRules":[{"action":"allow"}]}}]}`
+	seedTemplate(t, stock)
+	// InitDB pre-seeds a fresh install's rows, so the earlier two are already
+	// recorded and this seeder has to look unapplied for the run to reach it.
+	for _, name := range []string{"FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock"} {
+		var count int64
+		if err := db.Model(&model.HistoryOfSeeders{}).
+			Where("seeder_name = ?", name).Count(&count).Error; err != nil {
+			t.Fatalf("count %s: %v", name, err)
+		}
+		if count == 0 {
+			t.Fatalf("%s is no longer pre-seeded on a fresh install", name)
+		}
+	}
+	if err := db.Where("seeder_name = ?", "UppercaseFreedomFinalRulesFix").
+		Delete(&model.HistoryOfSeeders{}).Error; err != nil {
+		t.Fatalf("clear seeder history: %v", err)
+	}
+
+	if err := runSeeders(false); err != nil {
+		t.Fatalf("runSeeders: %v", err)
+	}
+
+	var cfg struct {
+		Outbounds []map[string]any `json:"outbounds"`
+	}
+	if err := json.Unmarshal([]byte(storedTemplate(t)), &cfg); err != nil {
+		t.Fatalf("stored template is not JSON: %v", err)
+	}
+	settings, _ := cfg.Outbounds[0]["settings"].(map[string]any)
+	if settings["finalRules"] == nil {
+		t.Fatal("the hardening never reached an outbound spelled Freedom")
+	}
+	if proto, _ := cfg.Outbounds[0]["protocol"].(string); proto != "Freedom" {
+		t.Fatalf("the seeder rewrote the protocol id: %q", proto)
+	}
+	if hardened := storedTemplate(t); !strings.Contains(hardened, `"geoip:private"`) {
+		t.Fatalf("stored finalRules are not the hardened pair: %s", hardened)
+	}
+
+	// A second pass must leave the template alone: the row it just wrote gates it.
+	after := storedTemplate(t)
+	if err := runSeeders(false); err != nil {
+		t.Fatalf("second runSeeders: %v", err)
+	}
+	if got := storedTemplate(t); got != after {
+		t.Fatalf("the re-run seed rewrote the template again:\n got %s\nwant %s", got, after)
+	}
+}
+
+// The core lowercases a protocol id before looking up its handler, so a config
+// that runs as freedom must migrate the same whether it says Freedom or freedom.
+func TestRewritersTreatProtocolCaseAlike(t *testing.T) {
+	tests := []struct {
+		name    string
+		rewrite func(string) (string, bool, error)
+		raw     string
+	}{
+		{
+			name:    "removed outbound keys",
+			rewrite: rewriteRemovedOutboundKeys,
+			raw:     `{"outbounds":[{"protocol":"freedom","tag":"direct","settings":{},"streamSettings":{"sockopt":{"addressPortStrategy":"SrvPortOnly"}}}]}`,
+		},
+		{
+			name:    "freedom final rules reverse",
+			rewrite: rewriteFreedomFinalRules,
+			raw:     `{"outbounds":[{"protocol":"freedom","tag":"direct","settings":{"finalRules":[{"action":"allow","ip":["geoip:private"]}]}}]}`,
+		},
+		{
+			name:    "freedom private egress block",
+			rewrite: rewriteFreedomFinalRulesPrivateEgress,
+			raw:     `{"outbounds":[{"protocol":"freedom","tag":"direct","settings":{"finalRules":[]}}]}`,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			want, wantChanged, err := tt.rewrite(tt.raw)
+			if err != nil || !wantChanged {
+				t.Fatalf("lowercase rewrite: changed=%v err=%v", wantChanged, err)
+			}
+			upper := strings.Replace(tt.raw, `"protocol":"freedom"`, `"protocol":"Freedom"`, 1)
+			got, changed, err := tt.rewrite(upper)
+			if err != nil {
+				t.Fatalf("uppercase rewrite: %v", err)
+			}
+			if !changed {
+				t.Fatalf(`the rewrite skipped an outbound spelled "Freedom"`)
+			}
+			if loweredGot, loweredWant := lowercaseProtocol(t, got), lowercaseProtocol(t, want); loweredGot != loweredWant {
+				t.Fatalf("uppercase result differs from lowercase:\n got %s\nwant %s", loweredGot, loweredWant)
+			}
+		})
+	}
+}
+
+// The rewriters keep the spelling they were given; only the migrated keys may
+// differ, so the comparison ignores the case of the protocol id itself.
+func lowercaseProtocol(t *testing.T, raw string) string {
+	t.Helper()
+	var cfg map[string]any
+	if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
+		t.Fatal(err)
+	}
+	outbounds, _ := cfg["outbounds"].([]any)
+	for _, ob := range outbounds {
+		obj, ok := ob.(map[string]any)
+		if !ok {
+			continue
+		}
+		if proto, ok := obj["protocol"].(string); ok {
+			obj["protocol"] = strings.ToLower(proto)
+		}
+	}
+	out, err := json.Marshal(cfg)
+	if err != nil {
+		t.Fatal(err)
+	}
+	return string(out)
+}

+ 17 - 3
internal/web/service/outbound/outbound.go

@@ -6,6 +6,7 @@ import (
 	"fmt"
 	"fmt"
 	"net"
 	"net"
 	"strconv"
 	"strconv"
+	"strings"
 	"sync"
 	"sync"
 	"time"
 	"time"
 
 
@@ -156,7 +157,7 @@ func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundRes
 	}
 	}
 	tag, _ := ob["tag"].(string)
 	tag, _ := ob["tag"].(string)
 	protocol, _ := ob["protocol"].(string)
 	protocol, _ := ob["protocol"].(string)
-	if protocol == "blackhole" || protocol == "freedom" || tag == "blocked" {
+	if equalsAnyFold(protocol, "blackhole", "freedom") || tag == "blocked" {
 		return &TestOutboundResult{Tag: tag, Mode: "tcp", Success: false, Error: "Outbound has no testable endpoint"}, nil
 		return &TestOutboundResult{Tag: tag, Mode: "tcp", Success: false, Error: "Outbound has no testable endpoint"}, nil
 	}
 	}
 
 
@@ -221,11 +222,23 @@ func probeTCPEndpoint(endpoint string, timeout time.Duration) TestEndpointResult
 // dial neither proves reachability nor measures latency. Such outbounds
 // dial neither proves reachability nor measures latency. Such outbounds
 // must go through the real xray handshake probe instead.
 // must go through the real xray handshake probe instead.
 func outboundTransportIsUDP(ob map[string]any) bool {
 func outboundTransportIsUDP(ob map[string]any) bool {
-	if protocol, _ := ob["protocol"].(string); protocol == "hysteria" || protocol == "wireguard" || protocol == "amneziawg" {
+	if protocol, _ := ob["protocol"].(string); equalsAnyFold(protocol, "hysteria", "wireguard", "amneziawg") {
 		return true
 		return true
 	}
 	}
 	if stream, ok := ob["streamSettings"].(map[string]any); ok {
 	if stream, ok := ob["streamSettings"].(map[string]any); ok {
-		if n, _ := stream["network"].(string); n == "hysteria" || n == "kcp" || n == "quic" {
+		// The core resolves "kcp" and "mkcp" to the same mKCP transport.
+		if n, _ := stream["network"].(string); equalsAnyFold(n, "hysteria", "kcp", "mkcp", "quic") {
+			return true
+		}
+	}
+	return false
+}
+
+// equalsAnyFold mirrors the core, which lowercases a protocol id and a
+// transport name before it resolves either of them.
+func equalsAnyFold(value string, want ...string) bool {
+	for _, w := range want {
+		if strings.EqualFold(value, w) {
 			return true
 			return true
 		}
 		}
 	}
 	}
@@ -234,6 +247,7 @@ func outboundTransportIsUDP(ob map[string]any) bool {
 
 
 func extractOutboundEndpoints(ob map[string]any) []string {
 func extractOutboundEndpoints(ob map[string]any) []string {
 	protocol, _ := ob["protocol"].(string)
 	protocol, _ := ob["protocol"].(string)
+	protocol = strings.ToLower(protocol)
 	settings, _ := ob["settings"].(map[string]any)
 	settings, _ := ob["settings"].(map[string]any)
 	if settings == nil {
 	if settings == nil {
 		return nil
 		return nil

+ 4 - 2
internal/web/service/outbound/probe_http.go

@@ -178,6 +178,8 @@ func (s *OutboundService) testOutboundsParsed(items []map[string]any, testURL st
 		r := &TestOutboundResult{Tag: tag, Mode: probeLabel}
 		r := &TestOutboundResult{Tag: tag, Mode: probeLabel}
 		results[i] = r
 		results[i] = r
 		protocol, _ := ob["protocol"].(string)
 		protocol, _ := ob["protocol"].(string)
+		// The core lowercases the id before it resolves the handler.
+		protocol = strings.ToLower(protocol)
 		switch {
 		switch {
 		case tag == "":
 		case tag == "":
 			r.Error = "Outbound has no tag"
 			r.Error = "Outbound has no tag"
@@ -394,7 +396,7 @@ func buildBatchTestConfig(items []*httpBatchItem, allOutbounds []any, ports []in
 			bridged = append(bridged, ob)
 			bridged = append(bridged, ob)
 			continue
 			continue
 		}
 		}
-		if p, _ := m["protocol"].(string); p != "amneziawg" {
+		if p, _ := m["protocol"].(string); !strings.EqualFold(p, "amneziawg") {
 			bridged = append(bridged, ob)
 			bridged = append(bridged, ob)
 			continue
 			continue
 		}
 		}
@@ -418,7 +420,7 @@ func buildBatchTestConfig(items []*httpBatchItem, allOutbounds []any, ports []in
 			continue
 			continue
 		}
 		}
 		// The temp instance must not touch kernel WireGuard devices.
 		// The temp instance must not touch kernel WireGuard devices.
-		if protocol, ok := outbound["protocol"].(string); ok && protocol == "wireguard" {
+		if protocol, ok := outbound["protocol"].(string); ok && strings.EqualFold(protocol, "wireguard") {
 			if settings, ok := outbound["settings"].(map[string]any); ok {
 			if settings, ok := outbound["settings"].(map[string]any); ok {
 				settings["noKernelTun"] = true
 				settings["noKernelTun"] = true
 			} else {
 			} else {

+ 174 - 0
internal/web/service/outbound/probe_protocol_case_test.go

@@ -0,0 +1,174 @@
+package outbound
+
+import (
+	"encoding/json"
+	"net"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+// The core lowercases a protocol id and a transport name before it resolves
+// either, so every reader here has to accept the spelling the core accepts.
+
+func TestTestOutboundsTCPModeForcesCoreSpelledUDPToHTTPProbe(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusNoContent)
+	}))
+	defer srv.Close()
+
+	withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess {
+		return &stubProcess{cfg: cfg, serveSocks: true}
+	})
+	withEgressTraceProbe(t, func(*url.URL) *TestEgressResult {
+		return &TestEgressResult{IPv4: "198.51.100.2", Country: "ZZ", Warp: "off"}
+	})
+
+	batch := mustJSON(t, []any{map[string]any{"tag": "wg", "protocol": "WireGuard"}})
+	results, err := (&OutboundService{}).TestOutbounds(batch, srv.URL, "", "tcp")
+	if err != nil {
+		t.Fatalf("TestOutbounds: %v", err)
+	}
+	r := results[0]
+	if !r.Success || r.Mode != "http" {
+		t.Errorf(`"WireGuard" outbound in tcp mode = %+v, want success with mode %q`, r, "http")
+	}
+	if r.Egress == nil || r.Egress.IPv4 != "198.51.100.2" {
+		t.Errorf(`"WireGuard" outbound egress = %+v`, r.Egress)
+	}
+}
+
+func TestOutboundTransportIsUDPMatchesTheCore(t *testing.T) {
+	tests := []struct {
+		name string
+		ob   map[string]any
+		want bool
+	}{
+		{"canonical wireguard", map[string]any{"protocol": "wireguard"}, true},
+		{"capitalised wireguard", map[string]any{"protocol": "WireGuard"}, true},
+		{"upper hysteria", map[string]any{"protocol": "HYSTERIA"}, true},
+		{"amneziawg", map[string]any{"protocol": "amneziawg"}, true},
+		{"kcp transport", map[string]any{"streamSettings": map[string]any{"network": "kcp"}}, true},
+		{"kcp transport capitalised", map[string]any{"streamSettings": map[string]any{"network": "KCP"}}, true},
+		{"mkcp alias", map[string]any{"streamSettings": map[string]any{"network": "mkcp"}}, true},
+		{"mkcp alias capitalised", map[string]any{"streamSettings": map[string]any{"network": "MKCP"}}, true},
+		{"tcp transport", map[string]any{"streamSettings": map[string]any{"network": "tcp"}}, false},
+		{"plain vless", map[string]any{"protocol": "vless"}, false},
+		{"matched but tcp", map[string]any{"protocol": "vless", "streamSettings": map[string]any{"network": "ws"}}, false},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := outboundTransportIsUDP(tt.ob); got != tt.want {
+				t.Errorf("outboundTransportIsUDP(%v) = %v, want %v", tt.ob, got, tt.want)
+			}
+		})
+	}
+}
+
+func TestBuildBatchTestConfigReadsTheProtocolIDLikeTheCore(t *testing.T) {
+	items := []*httpBatchItem{
+		{tag: "wg", outbound: map[string]any{"tag": "wg", "protocol": "WireGuard"}},
+		{tag: "awg", outbound: map[string]any{"tag": "awg", "protocol": "AmneziaWG"}},
+	}
+
+	cfg := buildBatchTestConfig(items, nil, []int{61011, 61012})
+	raw, err := json.Marshal(cfg)
+	if err != nil {
+		t.Fatalf("marshal config: %v", err)
+	}
+	var m map[string]any
+	if err := json.Unmarshal(raw, &m); err != nil {
+		t.Fatalf("unmarshal config: %v", err)
+	}
+
+	outbounds, _ := m["outbounds"].([]any)
+	byTag := make(map[string]map[string]any, len(outbounds))
+	for _, entry := range outbounds {
+		ob, _ := entry.(map[string]any)
+		tag, _ := ob["tag"].(string)
+		byTag[tag] = ob
+	}
+
+	wg := byTag["wg"]
+	if wg == nil {
+		t.Fatalf("wg outbound missing from the temp config: %v", outbounds)
+	}
+	if settings, _ := wg["settings"].(map[string]any); settings == nil || settings["noKernelTun"] != true {
+		t.Errorf(`"WireGuard" settings = %v, want noKernelTun: the probe instance must not create a kernel device`, wg["settings"])
+	}
+
+	awg := byTag["awg"]
+	if awg == nil {
+		t.Fatalf("awg outbound missing from the temp config: %v", outbounds)
+	}
+	if protocol, _ := awg["protocol"].(string); protocol != "socks" {
+		t.Errorf(`"AmneziaWG" protocol = %q, want %q: a raw amneziawg entry fails the whole temp config`, protocol, "socks")
+	}
+}
+
+func TestTestOutboundsRejectsUntestableIDsInAnyCase(t *testing.T) {
+	tests := []struct {
+		name     string
+		protocol string
+	}{
+		{"canonical freedom", "freedom"},
+		{"capitalised freedom", "Freedom"},
+		{"upper freedom", "FREEDOM"},
+		{"canonical blackhole", "blackhole"},
+		{"capitalised blackhole", "Blackhole"},
+	}
+
+	const wantErr = "Outbound has no testable endpoint"
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			batch := mustJSON(t, []any{map[string]any{"tag": "t1", "protocol": tt.protocol}})
+			results, err := (&OutboundService{}).TestOutbounds(batch, "", "", "tcp")
+			if err != nil {
+				t.Fatalf("TestOutbounds: %v", err)
+			}
+			r := results[0]
+			if r.Success {
+				t.Errorf("%q outbound = %+v, want a rejection", tt.protocol, r)
+			}
+			if r.Error != wantErr {
+				t.Errorf("%q error = %q, want %q", tt.protocol, r.Error, wantErr)
+			}
+		})
+	}
+}
+
+func TestTestOutboundsTCPLaneReadsProtocolIDCaseInsensitively(t *testing.T) {
+	l, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		t.Fatalf("listen: %v", err)
+	}
+	defer l.Close()
+	go func() {
+		for {
+			conn, err := l.Accept()
+			if err != nil {
+				return
+			}
+			conn.Close()
+		}
+	}()
+	port := l.Addr().(*net.TCPAddr).Port
+
+	batch := mustJSON(t, []any{map[string]any{
+		"tag":      "t1",
+		"protocol": "SOCKS",
+		"settings": map[string]any{"servers": []any{map[string]any{"address": "127.0.0.1", "port": port}}},
+	}})
+	results, err := (&OutboundService{}).TestOutbounds(batch, "", "", "tcp")
+	if err != nil {
+		t.Fatalf("TestOutbounds: %v", err)
+	}
+	r := results[0]
+	if !r.Success || r.Mode != "tcp" || len(r.Endpoints) != 1 {
+		t.Errorf(`"SOCKS" outbound in tcp mode = %+v, want a successful tcp probe with one endpoint`, r)
+	}
+}

+ 54 - 0
internal/web/service/outbound/probe_testability_gate_test.go

@@ -0,0 +1,54 @@
+package outbound
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+// A direct, DNS, loopback or blackhole outbound is not a tunnel, so the probe
+// rejects it whatever the spelling: the core resolves "Freedom" to freedom.
+func TestTestOutboundsRejectsCaseVariantUntestableIDs(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusNoContent)
+	}))
+	defer srv.Close()
+
+	withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess {
+		return &stubProcess{cfg: cfg, serveSocks: true}
+	})
+	withEgressTraceProbe(t, func(*url.URL) *TestEgressResult {
+		return &TestEgressResult{IPv4: "198.51.100.2", Country: "ZZ", Warp: "off"}
+	})
+
+	tests := []struct {
+		name     string
+		protocol string
+		wantErr  string
+	}{
+		{"freedom", "freedom", "Direct/DNS outbound cannot be tested"},
+		{"Freedom", "Freedom", "Direct/DNS outbound cannot be tested"},
+		{"FREEDOM", "FREEDOM", "Direct/DNS outbound cannot be tested"},
+		{"DNS", "DNS", "Direct/DNS outbound cannot be tested"},
+		{"Blackhole", "Blackhole", "Blocked/blackhole outbound cannot be tested"},
+		{"Loopback", "Loopback", "Loopback outbound cannot be tested"},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			batch := mustJSON(t, []any{map[string]any{"tag": "probe", "protocol": tt.protocol}})
+			results, err := (&OutboundService{}).TestOutbounds(batch, srv.URL, "", "http")
+			if err != nil {
+				t.Fatalf("TestOutbounds: %v", err)
+			}
+			r := results[0]
+			if r.Success || r.Error != tt.wantErr {
+				t.Errorf("%q = success=%v err=%q egress=%+v, want the rejection %q",
+					tt.protocol, r.Success, r.Error, r.Egress, tt.wantErr)
+			}
+		})
+	}
+}

+ 3 - 2
internal/web/service/xray_setting_routing_sync.go

@@ -2,6 +2,7 @@ package service
 
 
 import (
 import (
 	"encoding/json"
 	"encoding/json"
+	"strings"
 )
 )
 
 
 var routingMatcherKeys = []string{
 var routingMatcherKeys = []string{
@@ -142,7 +143,7 @@ func replaceInboundTagInOutbounds(outbounds []any, oldTag, newTag string) bool {
 			continue
 			continue
 		}
 		}
 		proto, _ := out["protocol"].(string)
 		proto, _ := out["protocol"].(string)
-		if proto != "loopback" {
+		if !strings.EqualFold(proto, "loopback") {
 			continue
 			continue
 		}
 		}
 		settings, ok := out["settings"].(map[string]any)
 		settings, ok := out["settings"].(map[string]any)
@@ -167,7 +168,7 @@ func removeInboundTagFromOutbounds(outbounds []any, deletedTag string) bool {
 			continue
 			continue
 		}
 		}
 		proto, _ := out["protocol"].(string)
 		proto, _ := out["protocol"].(string)
-		if proto != "loopback" {
+		if !strings.EqualFold(proto, "loopback") {
 			continue
 			continue
 		}
 		}
 		settings, ok := out["settings"].(map[string]any)
 		settings, ok := out["settings"].(map[string]any)

+ 67 - 0
internal/web/service/xray_setting_routing_sync_test.go

@@ -221,6 +221,73 @@ func TestRemoveInboundTagReferences_RemovesOneTagFromMultiInboundRule(t *testing
 	}
 	}
 }
 }
 
 
+// The core lowercases a protocol id before resolving the handler, so "Loopback"
+// is the loopback outbound whose inboundTag has to follow the inbound it names.
+func TestReplaceInboundTagInOutbounds_ReadsTheProtocolIDLikeTheCore(t *testing.T) {
+	tests := []struct {
+		name     string
+		protocol any
+		want     bool
+		wantTag  any
+	}{
+		{"canonical loopback is rewritten", "loopback", true, "new-tag"},
+		{"capitalised loopback is rewritten", "Loopback", true, "new-tag"},
+		{"uppercase loopback is rewritten", "LOOPBACK", true, "new-tag"},
+		{"another protocol keeps its tag", "vmess", false, "old-tag"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			outbounds := []any{map[string]any{
+				"protocol": tt.protocol,
+				"settings": map[string]any{"inboundTag": "old-tag"},
+			}}
+			if got := replaceInboundTagInOutbounds(outbounds, "old-tag", "new-tag"); got != tt.want {
+				t.Errorf("changed = %v, want %v", got, tt.want)
+			}
+			settings := outbounds[0].(map[string]any)["settings"].(map[string]any)
+			if got := settings["inboundTag"]; got != tt.wantTag {
+				t.Errorf("inboundTag = %v, want %v", got, tt.wantTag)
+			}
+		})
+	}
+}
+
+func TestRemoveInboundTagFromOutbounds_ReadsTheProtocolIDLikeTheCore(t *testing.T) {
+	tests := []struct {
+		name     string
+		protocol any
+		want     bool
+		wantTag  any
+	}{
+		{"canonical loopback is cleared", "loopback", true, nil},
+		{"capitalised loopback is cleared", "Loopback", true, nil},
+		{"uppercase loopback is cleared", "LOOPBACK", true, nil},
+		{"another protocol keeps its tag", "vmess", false, "gone-tag"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			outbounds := []any{map[string]any{
+				"protocol": tt.protocol,
+				"settings": map[string]any{"inboundTag": "gone-tag"},
+			}}
+			if got := removeInboundTagFromOutbounds(outbounds, "gone-tag"); got != tt.want {
+				t.Errorf("changed = %v, want %v", got, tt.want)
+			}
+			settings := outbounds[0].(map[string]any)["settings"].(map[string]any)
+			got, ok := settings["inboundTag"]
+			if tt.wantTag == nil {
+				if ok {
+					t.Errorf("inboundTag = %v, want the key gone", got)
+				}
+				return
+			}
+			if !ok || got != tt.wantTag {
+				t.Errorf("inboundTag = %v (present=%v), want %v", got, ok, tt.wantTag)
+			}
+		})
+	}
+}
+
 func findRuleByOutbound(t *testing.T, template, outbound string) map[string]any {
 func findRuleByOutbound(t *testing.T, template, outbound string) map[string]any {
 	t.Helper()
 	t.Helper()
 	for _, rule := range routingRulesFromTemplate(t, template) {
 	for _, rule := range routingRulesFromTemplate(t, template) {