2 Commits 16b9b3ce1c ... c77608bc47

Author SHA1 Message Date
  n0ctal c77608bc47 fix(nodes): make node API tokens write-only (#5613) 6 hours ago
  Sanaei 892c06c8bc Bug-label issue sweep: 16 fixes (#6083) 6 hours ago
62 changed files with 2888 additions and 173 deletions
  1. 428 29
      frontend/public/openapi.json
  2. 65 1
      frontend/src/generated/examples.ts
  3. 302 5
      frontend/src/generated/schemas.ts
  4. 63 1
      frontend/src/generated/types.ts
  5. 65 1
      frontend/src/generated/zod.ts
  6. 8 0
      frontend/src/lib/xray/inbound-form-adapter.ts
  7. 7 5
      frontend/src/pages/api-docs/endpoints.ts
  8. 2 1
      frontend/src/pages/inbounds/form/useSecurityActions.ts
  9. 15 5
      frontend/src/pages/nodes/NodeFormModal.tsx
  10. 12 3
      frontend/src/pages/xray/outbounds/OutboundsTab.tsx
  11. 3 1
      frontend/src/schemas/node.ts
  12. 16 0
      frontend/src/test/inbound-form-adapter.test.ts
  13. 193 5
      internal/database/db.go
  14. 87 0
      internal/database/freedom_finalrules_migration_test.go
  15. 82 0
      internal/database/journal_mode_test.go
  16. 2 1
      internal/database/model/model.go
  17. 93 0
      internal/database/reality_finalmask_migration_test.go
  18. 14 0
      internal/sub/host_sub.go
  19. 33 0
      internal/sub/host_sub_test.go
  20. 4 4
      internal/web/controller/client.go
  21. 42 39
      internal/web/controller/node.go
  22. 192 0
      internal/web/controller/node_credentials_writeonly_test.go
  23. 2 1
      internal/web/controller/server.go
  24. 6 3
      internal/web/controller/xray_setting.go
  25. 1 0
      internal/web/service/config.json
  26. 48 29
      internal/web/service/inbound.go
  27. 60 0
      internal/web/service/inbound_client_move_test.go
  28. 30 0
      internal/web/service/inbound_clients.go
  29. 78 0
      internal/web/service/inbound_fallback_runtime_test.go
  30. 8 1
      internal/web/service/inbound_node.go
  31. 123 0
      internal/web/service/node.go
  32. 186 0
      internal/web/service/node_contract.go
  33. 201 0
      internal/web/service/node_credentials_writeonly_test.go
  34. 8 0
      internal/web/service/node_tree.go
  35. 32 10
      internal/web/service/outbound_subscription.go
  36. 1 1
      internal/web/service/outbound_subscription_ssrf_test.go
  37. 69 6
      internal/web/service/reality_scan.go
  38. 57 2
      internal/web/service/reality_scan_test.go
  39. 18 5
      internal/web/service/xray_metrics.go
  40. 48 0
      internal/web/service/xray_metrics_test.go
  41. 37 7
      internal/web/service/xray_setting_dns_routing.go
  42. 63 7
      internal/web/service/xray_setting_dns_routing_test.go
  43. 1 0
      internal/web/translation/ar-EG.json
  44. 1 0
      internal/web/translation/en-US.json
  45. 1 0
      internal/web/translation/es-ES.json
  46. 1 0
      internal/web/translation/fa-IR.json
  47. 1 0
      internal/web/translation/id-ID.json
  48. 1 0
      internal/web/translation/ja-JP.json
  49. 1 0
      internal/web/translation/pt-BR.json
  50. 1 0
      internal/web/translation/ru-RU.json
  51. 1 0
      internal/web/translation/tr-TR.json
  52. 1 0
      internal/web/translation/uk-UA.json
  53. 1 0
      internal/web/translation/vi-VN.json
  54. 1 0
      internal/web/translation/zh-CN.json
  55. 1 0
      internal/web/translation/zh-TW.json
  56. 4 0
      internal/xray/api.go
  57. 17 0
      internal/xray/hot_diff.go
  58. 42 0
      internal/xray/hot_diff_test.go
  59. 2 0
      tools/openapigen/main.go
  60. 2 0
      x-ui.service.arch
  61. 2 0
      x-ui.service.debian
  62. 2 0
      x-ui.service.rhel

+ 428 - 29
frontend/public/openapi.json

@@ -2058,10 +2058,6 @@
           "allowPrivateAddress": {
           "allowPrivateAddress": {
             "type": "boolean"
             "type": "boolean"
           },
           },
-          "apiToken": {
-            "example": "abcdef0123456789",
-            "type": "string"
-          },
           "basePath": {
           "basePath": {
             "example": "/",
             "example": "/",
             "type": "string"
             "type": "string"
@@ -2232,7 +2228,6 @@
           "activeCount",
           "activeCount",
           "address",
           "address",
           "allowPrivateAddress",
           "allowPrivateAddress",
-          "apiToken",
           "basePath",
           "basePath",
           "clientCount",
           "clientCount",
           "configDirty",
           "configDirty",
@@ -2271,6 +2266,308 @@
         ],
         ],
         "type": "object"
         "type": "object"
       },
       },
+      "NodeMutationRequest": {
+        "description": "NodeMutationRequest is the node write/probe contract. ApiToken is accepted\nonly as input. On update, nil means keep the stored token; replacement and\nclearing are explicit and mutually exclusive.",
+        "properties": {
+          "address": {
+            "type": "string"
+          },
+          "allowPrivateAddress": {
+            "type": "boolean"
+          },
+          "apiToken": {
+            "nullable": true,
+            "type": "string"
+          },
+          "basePath": {
+            "type": "string"
+          },
+          "clearApiToken": {
+            "type": "boolean"
+          },
+          "enable": {
+            "type": "boolean"
+          },
+          "id": {
+            "type": "integer"
+          },
+          "inboundSyncMode": {
+            "enum": [
+              "all",
+              "selected"
+            ],
+            "type": "string"
+          },
+          "inboundTags": {
+            "items": {
+              "type": "string"
+            },
+            "type": "array"
+          },
+          "name": {
+            "type": "string"
+          },
+          "outboundTag": {
+            "type": "string"
+          },
+          "pinnedCertSha256": {
+            "type": "string"
+          },
+          "port": {
+            "maximum": 65535,
+            "minimum": 1,
+            "type": "integer"
+          },
+          "remark": {
+            "type": "string"
+          },
+          "scheme": {
+            "enum": [
+              "http",
+              "https"
+            ],
+            "type": "string"
+          },
+          "tlsVerifyMode": {
+            "enum": [
+              "verify",
+              "skip",
+              "pin",
+              "mtls"
+            ],
+            "type": "string"
+          }
+        },
+        "required": [
+          "address",
+          "allowPrivateAddress",
+          "basePath",
+          "enable",
+          "id",
+          "inboundSyncMode",
+          "inboundTags",
+          "name",
+          "outboundTag",
+          "pinnedCertSha256",
+          "port",
+          "remark",
+          "scheme",
+          "tlsVerifyMode"
+        ],
+        "type": "object"
+      },
+      "NodeView": {
+        "description": "NodeView is the browser/API read contract for nodes. Credentials are\nwrite-only: responses expose only whether a node has a token configured.",
+        "properties": {
+          "activeCount": {
+            "example": 20,
+            "type": "integer"
+          },
+          "address": {
+            "example": "node.example.com",
+            "type": "string"
+          },
+          "allowPrivateAddress": {
+            "example": false,
+            "type": "boolean"
+          },
+          "basePath": {
+            "example": "/",
+            "type": "string"
+          },
+          "clientCount": {
+            "example": 25,
+            "type": "integer"
+          },
+          "configDirty": {
+            "example": false,
+            "type": "boolean"
+          },
+          "configDirtyAt": {
+            "example": 0,
+            "type": "integer"
+          },
+          "cpuPct": {
+            "example": 12.5,
+            "type": "number"
+          },
+          "createdAt": {
+            "example": 1700000000,
+            "type": "integer"
+          },
+          "depletedCount": {
+            "example": 1,
+            "type": "integer"
+          },
+          "disabledCount": {
+            "example": 2,
+            "type": "integer"
+          },
+          "enable": {
+            "example": true,
+            "type": "boolean"
+          },
+          "guid": {
+            "example": "node-guid",
+            "type": "string"
+          },
+          "hasApiToken": {
+            "example": true,
+            "type": "boolean"
+          },
+          "id": {
+            "example": 1,
+            "type": "integer"
+          },
+          "inboundCount": {
+            "example": 3,
+            "type": "integer"
+          },
+          "inboundSyncMode": {
+            "example": "all",
+            "type": "string"
+          },
+          "inboundTags": {
+            "example": [
+              "in-443-tcp"
+            ],
+            "items": {
+              "type": "string"
+            },
+            "type": "array"
+          },
+          "lastError": {
+            "type": "string"
+          },
+          "lastHeartbeat": {
+            "example": 1700000000,
+            "type": "integer"
+          },
+          "latencyMs": {
+            "example": 42,
+            "type": "integer"
+          },
+          "memPct": {
+            "example": 45.2,
+            "type": "number"
+          },
+          "name": {
+            "example": "edge-1",
+            "type": "string"
+          },
+          "netDown": {
+            "example": 1048576,
+            "type": "integer"
+          },
+          "netUp": {
+            "example": 2097152,
+            "type": "integer"
+          },
+          "onlineCount": {
+            "example": 5,
+            "type": "integer"
+          },
+          "outboundTag": {
+            "example": "direct",
+            "type": "string"
+          },
+          "panelVersion": {
+            "example": "v3.x.x",
+            "type": "string"
+          },
+          "parentGuid": {
+            "type": "string"
+          },
+          "pinnedCertSha256": {
+            "type": "string"
+          },
+          "port": {
+            "example": 2053,
+            "type": "integer"
+          },
+          "remark": {
+            "example": "Primary edge",
+            "type": "string"
+          },
+          "scheme": {
+            "example": "https",
+            "type": "string"
+          },
+          "status": {
+            "example": "online",
+            "type": "string"
+          },
+          "tlsVerifyMode": {
+            "example": "verify",
+            "type": "string"
+          },
+          "transitive": {
+            "example": false,
+            "type": "boolean"
+          },
+          "updatedAt": {
+            "example": 1700003600,
+            "type": "integer"
+          },
+          "uptimeSecs": {
+            "example": 86400,
+            "type": "integer"
+          },
+          "xrayError": {
+            "type": "string"
+          },
+          "xrayState": {
+            "example": "running",
+            "type": "string"
+          },
+          "xrayVersion": {
+            "example": "25.10.31",
+            "type": "string"
+          }
+        },
+        "required": [
+          "activeCount",
+          "address",
+          "allowPrivateAddress",
+          "basePath",
+          "clientCount",
+          "configDirty",
+          "configDirtyAt",
+          "cpuPct",
+          "createdAt",
+          "depletedCount",
+          "disabledCount",
+          "enable",
+          "guid",
+          "hasApiToken",
+          "id",
+          "inboundCount",
+          "inboundSyncMode",
+          "inboundTags",
+          "lastError",
+          "lastHeartbeat",
+          "latencyMs",
+          "memPct",
+          "name",
+          "netDown",
+          "netUp",
+          "onlineCount",
+          "outboundTag",
+          "panelVersion",
+          "pinnedCertSha256",
+          "port",
+          "remark",
+          "scheme",
+          "status",
+          "tlsVerifyMode",
+          "updatedAt",
+          "uptimeSecs",
+          "xrayError",
+          "xrayState",
+          "xrayVersion"
+        ],
+        "type": "object"
+      },
       "OutboundTraffics": {
       "OutboundTraffics": {
         "description": "OutboundTraffics tracks traffic statistics for Xray outbound connections.",
         "description": "OutboundTraffics tracks traffic statistics for Xray outbound connections.",
         "properties": {
         "properties": {
@@ -7520,7 +7817,7 @@
                     "obj": {
                     "obj": {
                       "type": "array",
                       "type": "array",
                       "items": {
                       "items": {
-                        "$ref": "#/components/schemas/Node"
+                        "$ref": "#/components/schemas/NodeView"
                       }
                       }
                     }
                     }
                   }
                   }
@@ -7529,48 +7826,48 @@
                   "success": true,
                   "success": true,
                   "obj": [
                   "obj": [
                     {
                     {
-                      "activeCount": 23,
-                      "address": "node1.example.com",
+                      "activeCount": 20,
+                      "address": "node.example.com",
                       "allowPrivateAddress": false,
                       "allowPrivateAddress": false,
-                      "apiToken": "abcdef0123456789",
                       "basePath": "/",
                       "basePath": "/",
-                      "clientCount": 27,
+                      "clientCount": 25,
                       "configDirty": false,
                       "configDirty": false,
                       "configDirtyAt": 0,
                       "configDirtyAt": 0,
-                      "cpuPct": 23.5,
+                      "cpuPct": 12.5,
                       "createdAt": 1700000000,
                       "createdAt": 1700000000,
                       "depletedCount": 1,
                       "depletedCount": 1,
-                      "disabledCount": 3,
+                      "disabledCount": 2,
                       "enable": true,
                       "enable": true,
-                      "guid": "",
+                      "guid": "node-guid",
+                      "hasApiToken": true,
                       "id": 1,
                       "id": 1,
-                      "inboundCount": 5,
+                      "inboundCount": 3,
                       "inboundSyncMode": "all",
                       "inboundSyncMode": "all",
                       "inboundTags": [
                       "inboundTags": [
-                        ""
+                        "in-443-tcp"
                       ],
                       ],
                       "lastError": "",
                       "lastError": "",
                       "lastHeartbeat": 1700000000,
                       "lastHeartbeat": 1700000000,
                       "latencyMs": 42,
                       "latencyMs": 42,
-                      "memPct": 45.1,
-                      "name": "de-fra-1",
-                      "netDown": 2097152,
-                      "netUp": 1048576,
-                      "onlineCount": 3,
-                      "outboundTag": "",
+                      "memPct": 45.2,
+                      "name": "edge-1",
+                      "netDown": 1048576,
+                      "netUp": 2097152,
+                      "onlineCount": 5,
+                      "outboundTag": "direct",
                       "panelVersion": "v3.x.x",
                       "panelVersion": "v3.x.x",
                       "parentGuid": "",
                       "parentGuid": "",
                       "pinnedCertSha256": "",
                       "pinnedCertSha256": "",
                       "port": 2053,
                       "port": 2053,
-                      "remark": "",
+                      "remark": "Primary edge",
                       "scheme": "https",
                       "scheme": "https",
                       "status": "online",
                       "status": "online",
                       "tlsVerifyMode": "verify",
                       "tlsVerifyMode": "verify",
                       "transitive": false,
                       "transitive": false,
-                      "updatedAt": 1700000000,
+                      "updatedAt": 1700003600,
                       "uptimeSecs": 86400,
                       "uptimeSecs": 86400,
                       "xrayError": "",
                       "xrayError": "",
-                      "xrayState": "",
+                      "xrayState": "running",
                       "xrayVersion": "25.10.31"
                       "xrayVersion": "25.10.31"
                     }
                     }
                   ]
                   ]
@@ -7692,7 +7989,57 @@
                     "msg": {
                     "msg": {
                       "type": "string"
                       "type": "string"
                     },
                     },
-                    "obj": {}
+                    "obj": {
+                      "$ref": "#/components/schemas/NodeView"
+                    }
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": {
+                    "activeCount": 20,
+                    "address": "node.example.com",
+                    "allowPrivateAddress": false,
+                    "basePath": "/",
+                    "clientCount": 25,
+                    "configDirty": false,
+                    "configDirtyAt": 0,
+                    "cpuPct": 12.5,
+                    "createdAt": 1700000000,
+                    "depletedCount": 1,
+                    "disabledCount": 2,
+                    "enable": true,
+                    "guid": "node-guid",
+                    "hasApiToken": true,
+                    "id": 1,
+                    "inboundCount": 3,
+                    "inboundSyncMode": "all",
+                    "inboundTags": [
+                      "in-443-tcp"
+                    ],
+                    "lastError": "",
+                    "lastHeartbeat": 1700000000,
+                    "latencyMs": 42,
+                    "memPct": 45.2,
+                    "name": "edge-1",
+                    "netDown": 1048576,
+                    "netUp": 2097152,
+                    "onlineCount": 5,
+                    "outboundTag": "direct",
+                    "panelVersion": "v3.x.x",
+                    "parentGuid": "",
+                    "pinnedCertSha256": "",
+                    "port": 2053,
+                    "remark": "Primary edge",
+                    "scheme": "https",
+                    "status": "online",
+                    "tlsVerifyMode": "verify",
+                    "transitive": false,
+                    "updatedAt": 1700003600,
+                    "uptimeSecs": 86400,
+                    "xrayError": "",
+                    "xrayState": "running",
+                    "xrayVersion": "25.10.31"
                   }
                   }
                 }
                 }
               }
               }
@@ -7754,7 +8101,7 @@
         "tags": [
         "tags": [
           "Nodes"
           "Nodes"
         ],
         ],
-        "summary": "Register a new remote node. Provide its URL, apiToken, and optional remark / allowPrivateAddress flag.",
+        "summary": "Register a new remote node. Provide its URL, write-only apiToken, and optional remark / allowPrivateAddress flag. Responses expose hasApiToken only.",
         "operationId": "post_panel_api_nodes_add",
         "operationId": "post_panel_api_nodes_add",
         "requestBody": {
         "requestBody": {
           "required": true,
           "required": true,
@@ -7771,6 +8118,7 @@
                 "port": 2053,
                 "port": 2053,
                 "basePath": "/",
                 "basePath": "/",
                 "apiToken": "abcdef...",
                 "apiToken": "abcdef...",
+                "clearApiToken": false,
                 "enable": true,
                 "enable": true,
                 "allowPrivateAddress": false
                 "allowPrivateAddress": false
               }
               }
@@ -7791,7 +8139,57 @@
                     "msg": {
                     "msg": {
                       "type": "string"
                       "type": "string"
                     },
                     },
-                    "obj": {}
+                    "obj": {
+                      "$ref": "#/components/schemas/NodeView"
+                    }
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": {
+                    "activeCount": 20,
+                    "address": "node.example.com",
+                    "allowPrivateAddress": false,
+                    "basePath": "/",
+                    "clientCount": 25,
+                    "configDirty": false,
+                    "configDirtyAt": 0,
+                    "cpuPct": 12.5,
+                    "createdAt": 1700000000,
+                    "depletedCount": 1,
+                    "disabledCount": 2,
+                    "enable": true,
+                    "guid": "node-guid",
+                    "hasApiToken": true,
+                    "id": 1,
+                    "inboundCount": 3,
+                    "inboundSyncMode": "all",
+                    "inboundTags": [
+                      "in-443-tcp"
+                    ],
+                    "lastError": "",
+                    "lastHeartbeat": 1700000000,
+                    "latencyMs": 42,
+                    "memPct": 45.2,
+                    "name": "edge-1",
+                    "netDown": 1048576,
+                    "netUp": 2097152,
+                    "onlineCount": 5,
+                    "outboundTag": "direct",
+                    "panelVersion": "v3.x.x",
+                    "parentGuid": "",
+                    "pinnedCertSha256": "",
+                    "port": 2053,
+                    "remark": "Primary edge",
+                    "scheme": "https",
+                    "status": "online",
+                    "tlsVerifyMode": "verify",
+                    "transitive": false,
+                    "updatedAt": 1700003600,
+                    "uptimeSecs": 86400,
+                    "xrayError": "",
+                    "xrayState": "running",
+                    "xrayVersion": "25.10.31"
                   }
                   }
                 }
                 }
               }
               }
@@ -7805,7 +8203,7 @@
         "tags": [
         "tags": [
           "Nodes"
           "Nodes"
         ],
         ],
-        "summary": "Replace a node’s connection details. Same body shape as /add.",
+        "summary": "Replace a node’s connection details. apiToken is write-only: omit it or send an empty string to keep the stored token; set clearApiToken=true to clear it.",
         "operationId": "post_panel_api_nodes_update_id",
         "operationId": "post_panel_api_nodes_update_id",
         "parameters": [
         "parameters": [
           {
           {
@@ -7832,7 +8230,8 @@
                 "address": "node1.example.com",
                 "address": "node1.example.com",
                 "port": 2053,
                 "port": 2053,
                 "basePath": "/",
                 "basePath": "/",
-                "apiToken": "abcdef...",
+                "apiToken": "",
+                "clearApiToken": false,
                 "enable": true,
                 "enable": true,
                 "allowPrivateAddress": false
                 "allowPrivateAddress": false
               }
               }

+ 65 - 1
frontend/src/generated/examples.ts

@@ -494,7 +494,6 @@ export const EXAMPLES: Record<string, unknown> = {
     "activeCount": 23,
     "activeCount": 23,
     "address": "node1.example.com",
     "address": "node1.example.com",
     "allowPrivateAddress": false,
     "allowPrivateAddress": false,
-    "apiToken": "abcdef0123456789",
     "basePath": "/",
     "basePath": "/",
     "clientCount": 27,
     "clientCount": 27,
     "configDirty": false,
     "configDirty": false,
@@ -535,6 +534,71 @@ export const EXAMPLES: Record<string, unknown> = {
     "xrayState": "",
     "xrayState": "",
     "xrayVersion": "25.10.31"
     "xrayVersion": "25.10.31"
   },
   },
+  "NodeMutationRequest": {
+    "address": "",
+    "allowPrivateAddress": false,
+    "apiToken": null,
+    "basePath": "",
+    "clearApiToken": false,
+    "enable": false,
+    "id": 0,
+    "inboundSyncMode": "all",
+    "inboundTags": [
+      ""
+    ],
+    "name": "",
+    "outboundTag": "",
+    "pinnedCertSha256": "",
+    "port": 1,
+    "remark": "",
+    "scheme": "http",
+    "tlsVerifyMode": "verify"
+  },
+  "NodeView": {
+    "activeCount": 20,
+    "address": "node.example.com",
+    "allowPrivateAddress": false,
+    "basePath": "/",
+    "clientCount": 25,
+    "configDirty": false,
+    "configDirtyAt": 0,
+    "cpuPct": 12.5,
+    "createdAt": 1700000000,
+    "depletedCount": 1,
+    "disabledCount": 2,
+    "enable": true,
+    "guid": "node-guid",
+    "hasApiToken": true,
+    "id": 1,
+    "inboundCount": 3,
+    "inboundSyncMode": "all",
+    "inboundTags": [
+      "in-443-tcp"
+    ],
+    "lastError": "",
+    "lastHeartbeat": 1700000000,
+    "latencyMs": 42,
+    "memPct": 45.2,
+    "name": "edge-1",
+    "netDown": 1048576,
+    "netUp": 2097152,
+    "onlineCount": 5,
+    "outboundTag": "direct",
+    "panelVersion": "v3.x.x",
+    "parentGuid": "",
+    "pinnedCertSha256": "",
+    "port": 2053,
+    "remark": "Primary edge",
+    "scheme": "https",
+    "status": "online",
+    "tlsVerifyMode": "verify",
+    "transitive": false,
+    "updatedAt": 1700003600,
+    "uptimeSecs": 86400,
+    "xrayError": "",
+    "xrayState": "running",
+    "xrayVersion": "25.10.31"
+  },
   "OutboundTraffics": {
   "OutboundTraffics": {
     "down": 0,
     "down": 0,
     "id": 0,
     "id": 0,

+ 302 - 5
frontend/src/generated/schemas.ts

@@ -2032,10 +2032,6 @@ export const SCHEMAS: Record<string, unknown> = {
       "allowPrivateAddress": {
       "allowPrivateAddress": {
         "type": "boolean"
         "type": "boolean"
       },
       },
-      "apiToken": {
-        "example": "abcdef0123456789",
-        "type": "string"
-      },
       "basePath": {
       "basePath": {
         "example": "/",
         "example": "/",
         "type": "string"
         "type": "string"
@@ -2206,7 +2202,6 @@ export const SCHEMAS: Record<string, unknown> = {
       "activeCount",
       "activeCount",
       "address",
       "address",
       "allowPrivateAddress",
       "allowPrivateAddress",
-      "apiToken",
       "basePath",
       "basePath",
       "clientCount",
       "clientCount",
       "configDirty",
       "configDirty",
@@ -2245,6 +2240,308 @@ export const SCHEMAS: Record<string, unknown> = {
     ],
     ],
     "type": "object"
     "type": "object"
   },
   },
+  "NodeMutationRequest": {
+    "description": "NodeMutationRequest is the node write/probe contract. ApiToken is accepted\nonly as input. On update, nil means keep the stored token; replacement and\nclearing are explicit and mutually exclusive.",
+    "properties": {
+      "address": {
+        "type": "string"
+      },
+      "allowPrivateAddress": {
+        "type": "boolean"
+      },
+      "apiToken": {
+        "nullable": true,
+        "type": "string"
+      },
+      "basePath": {
+        "type": "string"
+      },
+      "clearApiToken": {
+        "type": "boolean"
+      },
+      "enable": {
+        "type": "boolean"
+      },
+      "id": {
+        "type": "integer"
+      },
+      "inboundSyncMode": {
+        "enum": [
+          "all",
+          "selected"
+        ],
+        "type": "string"
+      },
+      "inboundTags": {
+        "items": {
+          "type": "string"
+        },
+        "type": "array"
+      },
+      "name": {
+        "type": "string"
+      },
+      "outboundTag": {
+        "type": "string"
+      },
+      "pinnedCertSha256": {
+        "type": "string"
+      },
+      "port": {
+        "maximum": 65535,
+        "minimum": 1,
+        "type": "integer"
+      },
+      "remark": {
+        "type": "string"
+      },
+      "scheme": {
+        "enum": [
+          "http",
+          "https"
+        ],
+        "type": "string"
+      },
+      "tlsVerifyMode": {
+        "enum": [
+          "verify",
+          "skip",
+          "pin",
+          "mtls"
+        ],
+        "type": "string"
+      }
+    },
+    "required": [
+      "address",
+      "allowPrivateAddress",
+      "basePath",
+      "enable",
+      "id",
+      "inboundSyncMode",
+      "inboundTags",
+      "name",
+      "outboundTag",
+      "pinnedCertSha256",
+      "port",
+      "remark",
+      "scheme",
+      "tlsVerifyMode"
+    ],
+    "type": "object"
+  },
+  "NodeView": {
+    "description": "NodeView is the browser/API read contract for nodes. Credentials are\nwrite-only: responses expose only whether a node has a token configured.",
+    "properties": {
+      "activeCount": {
+        "example": 20,
+        "type": "integer"
+      },
+      "address": {
+        "example": "node.example.com",
+        "type": "string"
+      },
+      "allowPrivateAddress": {
+        "example": false,
+        "type": "boolean"
+      },
+      "basePath": {
+        "example": "/",
+        "type": "string"
+      },
+      "clientCount": {
+        "example": 25,
+        "type": "integer"
+      },
+      "configDirty": {
+        "example": false,
+        "type": "boolean"
+      },
+      "configDirtyAt": {
+        "example": 0,
+        "type": "integer"
+      },
+      "cpuPct": {
+        "example": 12.5,
+        "type": "number"
+      },
+      "createdAt": {
+        "example": 1700000000,
+        "type": "integer"
+      },
+      "depletedCount": {
+        "example": 1,
+        "type": "integer"
+      },
+      "disabledCount": {
+        "example": 2,
+        "type": "integer"
+      },
+      "enable": {
+        "example": true,
+        "type": "boolean"
+      },
+      "guid": {
+        "example": "node-guid",
+        "type": "string"
+      },
+      "hasApiToken": {
+        "example": true,
+        "type": "boolean"
+      },
+      "id": {
+        "example": 1,
+        "type": "integer"
+      },
+      "inboundCount": {
+        "example": 3,
+        "type": "integer"
+      },
+      "inboundSyncMode": {
+        "example": "all",
+        "type": "string"
+      },
+      "inboundTags": {
+        "example": [
+          "in-443-tcp"
+        ],
+        "items": {
+          "type": "string"
+        },
+        "type": "array"
+      },
+      "lastError": {
+        "type": "string"
+      },
+      "lastHeartbeat": {
+        "example": 1700000000,
+        "type": "integer"
+      },
+      "latencyMs": {
+        "example": 42,
+        "type": "integer"
+      },
+      "memPct": {
+        "example": 45.2,
+        "type": "number"
+      },
+      "name": {
+        "example": "edge-1",
+        "type": "string"
+      },
+      "netDown": {
+        "example": 1048576,
+        "type": "integer"
+      },
+      "netUp": {
+        "example": 2097152,
+        "type": "integer"
+      },
+      "onlineCount": {
+        "example": 5,
+        "type": "integer"
+      },
+      "outboundTag": {
+        "example": "direct",
+        "type": "string"
+      },
+      "panelVersion": {
+        "example": "v3.x.x",
+        "type": "string"
+      },
+      "parentGuid": {
+        "type": "string"
+      },
+      "pinnedCertSha256": {
+        "type": "string"
+      },
+      "port": {
+        "example": 2053,
+        "type": "integer"
+      },
+      "remark": {
+        "example": "Primary edge",
+        "type": "string"
+      },
+      "scheme": {
+        "example": "https",
+        "type": "string"
+      },
+      "status": {
+        "example": "online",
+        "type": "string"
+      },
+      "tlsVerifyMode": {
+        "example": "verify",
+        "type": "string"
+      },
+      "transitive": {
+        "example": false,
+        "type": "boolean"
+      },
+      "updatedAt": {
+        "example": 1700003600,
+        "type": "integer"
+      },
+      "uptimeSecs": {
+        "example": 86400,
+        "type": "integer"
+      },
+      "xrayError": {
+        "type": "string"
+      },
+      "xrayState": {
+        "example": "running",
+        "type": "string"
+      },
+      "xrayVersion": {
+        "example": "25.10.31",
+        "type": "string"
+      }
+    },
+    "required": [
+      "activeCount",
+      "address",
+      "allowPrivateAddress",
+      "basePath",
+      "clientCount",
+      "configDirty",
+      "configDirtyAt",
+      "cpuPct",
+      "createdAt",
+      "depletedCount",
+      "disabledCount",
+      "enable",
+      "guid",
+      "hasApiToken",
+      "id",
+      "inboundCount",
+      "inboundSyncMode",
+      "inboundTags",
+      "lastError",
+      "lastHeartbeat",
+      "latencyMs",
+      "memPct",
+      "name",
+      "netDown",
+      "netUp",
+      "onlineCount",
+      "outboundTag",
+      "panelVersion",
+      "pinnedCertSha256",
+      "port",
+      "remark",
+      "scheme",
+      "status",
+      "tlsVerifyMode",
+      "updatedAt",
+      "uptimeSecs",
+      "xrayError",
+      "xrayState",
+      "xrayVersion"
+    ],
+    "type": "object"
+  },
   "OutboundTraffics": {
   "OutboundTraffics": {
     "description": "OutboundTraffics tracks traffic statistics for Xray outbound connections.",
     "description": "OutboundTraffics tracks traffic statistics for Xray outbound connections.",
     "properties": {
     "properties": {

+ 63 - 1
frontend/src/generated/types.ts

@@ -477,7 +477,6 @@ export interface Node {
   activeCount: number;
   activeCount: number;
   address: string;
   address: string;
   allowPrivateAddress: boolean;
   allowPrivateAddress: boolean;
-  apiToken: string;
   basePath: string;
   basePath: string;
   clientCount: number;
   clientCount: number;
   configDirty: boolean;
   configDirty: boolean;
@@ -517,6 +516,69 @@ export interface Node {
   xrayVersion: string;
   xrayVersion: string;
 }
 }
 
 
+export interface NodeMutationRequest {
+  address: string;
+  allowPrivateAddress: boolean;
+  apiToken?: string | null;
+  basePath: string;
+  clearApiToken?: boolean;
+  enable: boolean;
+  id: number;
+  inboundSyncMode: string;
+  inboundTags: string[];
+  name: string;
+  outboundTag: string;
+  pinnedCertSha256: string;
+  port: number;
+  remark: string;
+  scheme: string;
+  tlsVerifyMode: string;
+}
+
+export interface NodeView {
+  activeCount: number;
+  address: string;
+  allowPrivateAddress: boolean;
+  basePath: string;
+  clientCount: number;
+  configDirty: boolean;
+  configDirtyAt: number;
+  cpuPct: number;
+  createdAt: number;
+  depletedCount: number;
+  disabledCount: number;
+  enable: boolean;
+  guid: string;
+  hasApiToken: boolean;
+  id: number;
+  inboundCount: number;
+  inboundSyncMode: string;
+  inboundTags: string[];
+  lastError: string;
+  lastHeartbeat: number;
+  latencyMs: number;
+  memPct: number;
+  name: string;
+  netDown: number;
+  netUp: number;
+  onlineCount: number;
+  outboundTag: string;
+  panelVersion: string;
+  parentGuid?: string;
+  pinnedCertSha256: string;
+  port: number;
+  remark: string;
+  scheme: string;
+  status: string;
+  tlsVerifyMode: string;
+  transitive?: boolean;
+  updatedAt: number;
+  uptimeSecs: number;
+  xrayError: string;
+  xrayState: string;
+  xrayVersion: string;
+}
+
 export interface OutboundTraffics {
 export interface OutboundTraffics {
   down: number;
   down: number;
   id: number;
   id: number;

+ 65 - 1
frontend/src/generated/zod.ts

@@ -507,7 +507,6 @@ export const NodeSchema = z.object({
   activeCount: z.number().int(),
   activeCount: z.number().int(),
   address: z.string(),
   address: z.string(),
   allowPrivateAddress: z.boolean(),
   allowPrivateAddress: z.boolean(),
-  apiToken: z.string(),
   basePath: z.string(),
   basePath: z.string(),
   clientCount: z.number().int(),
   clientCount: z.number().int(),
   configDirty: z.boolean(),
   configDirty: z.boolean(),
@@ -548,6 +547,71 @@ export const NodeSchema = z.object({
 });
 });
 export type Node = z.infer<typeof NodeSchema>;
 export type Node = z.infer<typeof NodeSchema>;
 
 
+export const NodeMutationRequestSchema = z.object({
+  address: z.string(),
+  allowPrivateAddress: z.boolean(),
+  apiToken: z.string().nullable().optional(),
+  basePath: z.string(),
+  clearApiToken: z.boolean().optional(),
+  enable: z.boolean(),
+  id: z.number().int(),
+  inboundSyncMode: z.enum(['all', 'selected']),
+  inboundTags: z.array(z.string()),
+  name: z.string(),
+  outboundTag: z.string(),
+  pinnedCertSha256: z.string(),
+  port: z.number().int().min(1).max(65535),
+  remark: z.string(),
+  scheme: z.enum(['http', 'https']),
+  tlsVerifyMode: z.enum(['verify', 'skip', 'pin', 'mtls']),
+});
+export type NodeMutationRequest = z.infer<typeof NodeMutationRequestSchema>;
+
+export const NodeViewSchema = z.object({
+  activeCount: z.number().int(),
+  address: z.string(),
+  allowPrivateAddress: z.boolean(),
+  basePath: z.string(),
+  clientCount: z.number().int(),
+  configDirty: z.boolean(),
+  configDirtyAt: z.number().int(),
+  cpuPct: z.number(),
+  createdAt: z.number().int(),
+  depletedCount: z.number().int(),
+  disabledCount: z.number().int(),
+  enable: z.boolean(),
+  guid: z.string(),
+  hasApiToken: z.boolean(),
+  id: z.number().int(),
+  inboundCount: z.number().int(),
+  inboundSyncMode: z.string(),
+  inboundTags: z.array(z.string()),
+  lastError: z.string(),
+  lastHeartbeat: z.number().int(),
+  latencyMs: z.number().int(),
+  memPct: z.number(),
+  name: z.string(),
+  netDown: z.number().int(),
+  netUp: z.number().int(),
+  onlineCount: z.number().int(),
+  outboundTag: z.string(),
+  panelVersion: z.string(),
+  parentGuid: z.string().optional(),
+  pinnedCertSha256: z.string(),
+  port: z.number().int(),
+  remark: z.string(),
+  scheme: z.string(),
+  status: z.string(),
+  tlsVerifyMode: z.string(),
+  transitive: z.boolean().optional(),
+  updatedAt: z.number().int(),
+  uptimeSecs: z.number().int(),
+  xrayError: z.string(),
+  xrayState: z.string(),
+  xrayVersion: z.string(),
+});
+export type NodeView = z.infer<typeof NodeViewSchema>;
+
 export const OutboundTrafficsSchema = z.object({
 export const OutboundTrafficsSchema = z.object({
   down: z.number().int(),
   down: z.number().int(),
   id: z.number().int(),
   id: z.number().int(),

+ 8 - 0
frontend/src/lib/xray/inbound-form-adapter.ts

@@ -14,6 +14,7 @@ import type { Sniffing } from '@/schemas/primitives';
 import type { z } from 'zod';
 import type { z } from 'zod';
 import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
 import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
 import { canEnableSniffing } from '@/lib/xray/protocol-capabilities';
 import { canEnableSniffing } from '@/lib/xray/protocol-capabilities';
+import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
 import { XHttpStreamSettingsSchema, XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
 import { XHttpStreamSettingsSchema, XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
 
 
 const XMUX_DEFAULTS = XHttpXmuxSchema.parse({});
 const XMUX_DEFAULTS = XHttpXmuxSchema.parse({});
@@ -178,6 +179,13 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
         xhttp.xmux = { ...XMUX_DEFAULTS, ...(xmux as Record<string, unknown>) };
         xhttp.xmux = { ...XMUX_DEFAULTS, ...(xmux as Record<string, unknown>) };
       }
       }
     }
     }
+    const so = streamRecord.sockopt;
+    if (so && typeof so === 'object' && !Array.isArray(so)) {
+      const parsed = SockoptStreamSettingsSchema.safeParse(so);
+      if (parsed.success) {
+        streamRecord.sockopt = { ...(so as Record<string, unknown>), ...parsed.data };
+      }
+    }
   }
   }
   const sniffing = coerceJsonObject(row.sniffing) as unknown as Sniffing;
   const sniffing = coerceJsonObject(row.sniffing) as unknown as Sniffing;
 
 

+ 7 - 5
frontend/src/pages/api-docs/endpoints.ts

@@ -905,7 +905,7 @@ export const sections: readonly Section[] = [
         method: 'GET',
         method: 'GET',
         path: '/panel/api/nodes/list',
         path: '/panel/api/nodes/list',
         summary: 'List every configured node with its connection details, health, and last heartbeat patch.',
         summary: 'List every configured node with its connection details, health, and last heartbeat patch.',
-        responseSchema: 'Node',
+        responseSchema: 'NodeView',
         responseSchemaArray: true,
         responseSchemaArray: true,
       },
       },
       {
       {
@@ -927,6 +927,7 @@ export const sections: readonly Section[] = [
         params: [
         params: [
           { name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
           { name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
         ],
         ],
+        responseSchema: 'NodeView',
       },
       },
       {
       {
         method: 'GET',
         method: 'GET',
@@ -940,18 +941,19 @@ export const sections: readonly Section[] = [
       {
       {
         method: 'POST',
         method: 'POST',
         path: '/panel/api/nodes/add',
         path: '/panel/api/nodes/add',
-        summary: 'Register a new remote node. Provide its URL, apiToken, and optional remark / allowPrivateAddress flag.',
+        summary: 'Register a new remote node. Provide its URL, write-only apiToken, and optional remark / allowPrivateAddress flag. Responses expose hasApiToken only.',
         body:
         body:
-          '{\n  "name": "de-fra-1",\n  "remark": "",\n  "scheme": "https",\n  "address": "node1.example.com",\n  "port": 2053,\n  "basePath": "/",\n  "apiToken": "abcdef...",\n  "enable": true,\n  "allowPrivateAddress": false\n}',
+          '{\n  "name": "de-fra-1",\n  "remark": "",\n  "scheme": "https",\n  "address": "node1.example.com",\n  "port": 2053,\n  "basePath": "/",\n  "apiToken": "abcdef...",\n  "clearApiToken": false,\n  "enable": true,\n  "allowPrivateAddress": false\n}',
+        responseSchema: 'NodeView',
       },
       },
       {
       {
         method: 'POST',
         method: 'POST',
         path: '/panel/api/nodes/update/:id',
         path: '/panel/api/nodes/update/:id',
-        summary: 'Replace a node\u2019s connection details. Same body shape as /add.',
+        summary: 'Replace a node\u2019s connection details. apiToken is write-only: omit it or send an empty string to keep the stored token; set clearApiToken=true to clear it.',
         params: [
         params: [
           { name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
           { name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
         ],
         ],
-        body: '{\n  "name": "de-fra-1",\n  "remark": "",\n  "scheme": "https",\n  "address": "node1.example.com",\n  "port": 2053,\n  "basePath": "/",\n  "apiToken": "abcdef...",\n  "enable": true,\n  "allowPrivateAddress": false\n}',
+        body: '{\n  "name": "de-fra-1",\n  "remark": "",\n  "scheme": "https",\n  "address": "node1.example.com",\n  "port": 2053,\n  "basePath": "/",\n  "apiToken": "",\n  "clearApiToken": false,\n  "enable": true,\n  "allowPrivateAddress": false\n}',
       },
       },
       {
       {
         method: 'POST',
         method: 'POST',

+ 2 - 1
frontend/src/pages/inbounds/form/useSecurityActions.ts

@@ -86,11 +86,12 @@ export function useSecurityActions({ methods, setSaving, messageApi, nodeId, set
       messageApi.warning(t('pages.inbounds.form.realityTargetRequired'));
       messageApi.warning(t('pages.inbounds.form.realityTargetRequired'));
       return;
       return;
     }
     }
+    const xver = Number(getValues('streamSettings.realitySettings.xver')) || 0;
     setScanning(true);
     setScanning(true);
     try {
     try {
       const msg = await HttpUtil.post<RealityScanResult>(
       const msg = await HttpUtil.post<RealityScanResult>(
         '/panel/api/server/scanRealityTarget',
         '/panel/api/server/scanRealityTarget',
-        { target },
+        { target, xver },
         { silent: true },
         { silent: true },
       );
       );
       if (!msg?.success || !msg.obj) {
       if (!msg?.success || !msg.obj) {

+ 15 - 5
frontend/src/pages/nodes/NodeFormModal.tsx

@@ -45,6 +45,7 @@ function defaultValues(): NodeFormValues {
     port: 2053,
     port: 2053,
     basePath: '/',
     basePath: '/',
     apiToken: '',
     apiToken: '',
+    hasStoredToken: false,
     enable: true,
     enable: true,
     allowPrivateAddress: false,
     allowPrivateAddress: false,
     tlsVerifyMode: 'verify',
     tlsVerifyMode: 'verify',
@@ -107,6 +108,8 @@ export default function NodeFormModal({
         scheme: (node.scheme as 'http' | 'https') || base.scheme,
         scheme: (node.scheme as 'http' | 'https') || base.scheme,
         inboundSyncMode: (node.inboundSyncMode as 'all' | 'selected') || base.inboundSyncMode,
         inboundSyncMode: (node.inboundSyncMode as 'all' | 'selected') || base.inboundSyncMode,
         inboundTags: node.inboundTags ?? [],
         inboundTags: node.inboundTags ?? [],
+        apiToken: '',
+        hasStoredToken: node.hasApiToken ?? false,
       }
       }
       : base;
       : base;
     if (next.scheme === 'http') next.tlsVerifyMode = 'skip';
     if (next.scheme === 'http') next.tlsVerifyMode = 'skip';
@@ -120,8 +123,11 @@ export default function NodeFormModal({
     [mode, t],
     [mode, t],
   );
   );
 
 
+  const editingWithToken = mode === 'edit' && Boolean(node?.hasApiToken);
+
   function buildPayload(values: NodeFormValues): Partial<NodeRecord> {
   function buildPayload(values: NodeFormValues): Partial<NodeRecord> {
-    return {
+    const token = values.apiToken.trim();
+    const payload: Partial<NodeRecord> = {
       id: values.id || 0,
       id: values.id || 0,
       name: values.name.trim(),
       name: values.name.trim(),
       remark: values.remark?.trim() || '',
       remark: values.remark?.trim() || '',
@@ -129,7 +135,6 @@ export default function NodeFormModal({
       address: values.address.trim(),
       address: values.address.trim(),
       port: values.port,
       port: values.port,
       basePath: values.basePath.trim() || '/',
       basePath: values.basePath.trim() || '/',
-      apiToken: values.apiToken.trim(),
       enable: values.enable,
       enable: values.enable,
       allowPrivateAddress: values.allowPrivateAddress,
       allowPrivateAddress: values.allowPrivateAddress,
       tlsVerifyMode: values.tlsVerifyMode,
       tlsVerifyMode: values.tlsVerifyMode,
@@ -138,10 +143,12 @@ export default function NodeFormModal({
       inboundTags: values.inboundSyncMode === 'selected' ? values.inboundTags : [],
       inboundTags: values.inboundSyncMode === 'selected' ? values.inboundTags : [],
       outboundTag: values.outboundTag || '',
       outboundTag: values.outboundTag || '',
     };
     };
+    if (token) payload.apiToken = token;
+    return payload;
   }
   }
 
 
   async function onTest() {
   async function onTest() {
-    if (!(await methods.trigger(['address', 'port']))) return;
+    if (!(await methods.trigger(['name', 'address', 'port']))) return;
     setTesting(true);
     setTesting(true);
     setTestResult(null);
     setTestResult(null);
     try {
     try {
@@ -158,7 +165,7 @@ export default function NodeFormModal({
   }
   }
 
 
   async function onFetchPin() {
   async function onFetchPin() {
-    if (!(await methods.trigger(['address', 'port']))) return;
+    if (!(await methods.trigger(['name', 'address', 'port']))) return;
     setFetchingPin(true);
     setFetchingPin(true);
     try {
     try {
       const payload = buildPayload(methods.getValues());
       const payload = buildPayload(methods.getValues());
@@ -369,8 +376,11 @@ export default function NodeFormModal({
               name="apiToken"
               name="apiToken"
               rules={{ validate: rhfZodValidate(NodeFormSchema.shape.apiToken) }}
               rules={{ validate: rhfZodValidate(NodeFormSchema.shape.apiToken) }}
               tooltip={t('pages.nodes.apiTokenHint')}
               tooltip={t('pages.nodes.apiTokenHint')}
+              extra={editingWithToken ? t('pages.nodes.apiTokenKeepHint') : undefined}
             >
             >
-              <Input.Password placeholder={t('pages.nodes.apiTokenPlaceholder')} />
+              <Input.Password
+                placeholder={editingWithToken ? t('pages.nodes.apiTokenKeepHint') : t('pages.nodes.apiTokenPlaceholder')}
+              />
             </FormField>
             </FormField>
 
 
             <FormField
             <FormField

+ 12 - 3
frontend/src/pages/xray/outbounds/OutboundsTab.tsx

@@ -61,6 +61,7 @@ interface OutboundSub {
   url?: string;
   url?: string;
   enabled?: boolean;
   enabled?: boolean;
   allowPrivate?: boolean;
   allowPrivate?: boolean;
+  allowInsecure?: boolean;
   prepend?: boolean;
   prepend?: boolean;
   priority?: number;
   priority?: number;
   tagPrefix?: string;
   tagPrefix?: string;
@@ -122,7 +123,7 @@ export default function OutboundsTab({
   const [subDrawerOpen, setSubDrawerOpen] = useState(false);
   const [subDrawerOpen, setSubDrawerOpen] = useState(false);
   const [subs, setSubs] = useState<OutboundSub[]>([]);
   const [subs, setSubs] = useState<OutboundSub[]>([]);
   const [subsLoading, setSubsLoading] = useState(false);
   const [subsLoading, setSubsLoading] = useState(false);
-  const [newSub, setNewSub] = useState({ remark: '', url: '', tagPrefix: '', updateInterval: 600, enabled: true, allowPrivate: false, prepend: false });
+  const [newSub, setNewSub] = useState({ remark: '', url: '', tagPrefix: '', updateInterval: 600, enabled: true, allowPrivate: false, allowInsecure: false, prepend: false });
   const [editingSubId, setEditingSubId] = useState<number | null>(null);
   const [editingSubId, setEditingSubId] = useState<number | null>(null);
   const [savingSub, setSavingSub] = useState(false);
   const [savingSub, setSavingSub] = useState(false);
   const [refreshingId, setRefreshingId] = useState<number | null>(null);
   const [refreshingId, setRefreshingId] = useState<number | null>(null);
@@ -303,7 +304,7 @@ export default function OutboundsTab({
       setSubsLoading(false);
       setSubsLoading(false);
     }
     }
   }
   }
-  function subBody(src: { remark?: string; url?: string; tagPrefix?: string; updateInterval?: number; enabled?: boolean; allowPrivate?: boolean; prepend?: boolean }) {
+  function subBody(src: { remark?: string; url?: string; tagPrefix?: string; updateInterval?: number; enabled?: boolean; allowPrivate?: boolean; allowInsecure?: boolean; prepend?: boolean }) {
     return {
     return {
       remark: src.remark ?? '',
       remark: src.remark ?? '',
       url: src.url ?? '',
       url: src.url ?? '',
@@ -311,11 +312,12 @@ export default function OutboundsTab({
       updateInterval: src.updateInterval ?? 600,
       updateInterval: src.updateInterval ?? 600,
       enabled: src.enabled ?? true,
       enabled: src.enabled ?? true,
       allowPrivate: src.allowPrivate ?? false,
       allowPrivate: src.allowPrivate ?? false,
+      allowInsecure: src.allowInsecure ?? false,
       prepend: src.prepend ?? false,
       prepend: src.prepend ?? false,
     };
     };
   }
   }
   function resetSubForm() {
   function resetSubForm() {
-    setNewSub({ remark: '', url: '', tagPrefix: '', updateInterval: 600, enabled: true, allowPrivate: false, prepend: false });
+    setNewSub({ remark: '', url: '', tagPrefix: '', updateInterval: 600, enabled: true, allowPrivate: false, allowInsecure: false, prepend: false });
     setEditingSubId(null);
     setEditingSubId(null);
     setPreviewData(null);
     setPreviewData(null);
   }
   }
@@ -327,6 +329,7 @@ export default function OutboundsTab({
       updateInterval: sub.updateInterval ?? 600,
       updateInterval: sub.updateInterval ?? 600,
       enabled: sub.enabled ?? true,
       enabled: sub.enabled ?? true,
       allowPrivate: sub.allowPrivate ?? false,
       allowPrivate: sub.allowPrivate ?? false,
+      allowInsecure: sub.allowInsecure ?? false,
       prepend: sub.prepend ?? false,
       prepend: sub.prepend ?? false,
     });
     });
     setEditingSubId(sub.id);
     setEditingSubId(sub.id);
@@ -647,6 +650,12 @@ export default function OutboundsTab({
                   {t('pages.xray.outboundSub.allowPrivateHint')}
                   {t('pages.xray.outboundSub.allowPrivateHint')}
                 </div>
                 </div>
               </Form.Item>
               </Form.Item>
+              <Form.Item label={t('pages.hosts.fields.allowInsecure')}>
+                <Switch checked={newSub.allowInsecure} onChange={(v) => setNewSub({ ...newSub, allowInsecure: v })} />
+                <div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
+                  {t('pages.hosts.hints.allowInsecure')}
+                </div>
+              </Form.Item>
               <Form.Item label={t('pages.xray.outboundSub.prepend')}>
               <Form.Item label={t('pages.xray.outboundSub.prepend')}>
                 <Switch checked={newSub.prepend} onChange={(v) => setNewSub({ ...newSub, prepend: v })} />
                 <Switch checked={newSub.prepend} onChange={(v) => setNewSub({ ...newSub, prepend: v })} />
                 <div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
                 <div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>

+ 3 - 1
frontend/src/schemas/node.ts

@@ -9,6 +9,7 @@ export const NodeRecordSchema = z.object({
   port: z.number().optional(),
   port: z.number().optional(),
   basePath: z.string().optional(),
   basePath: z.string().optional(),
   apiToken: z.string().optional(),
   apiToken: z.string().optional(),
+  hasApiToken: z.boolean().optional(),
   enable: z.boolean().optional(),
   enable: z.boolean().optional(),
   status: z.string().optional(),
   status: z.string().optional(),
   latencyMs: z.number().optional(),
   latencyMs: z.number().optional(),
@@ -67,6 +68,7 @@ export const NodeFormSchema = z.object({
   // mTLS nodes authenticate via the client certificate, so the token is optional
   // mTLS nodes authenticate via the client certificate, so the token is optional
   // there; every other verify mode still requires one (matches remote.do()).
   // there; every other verify mode still requires one (matches remote.do()).
   apiToken: z.string().trim(),
   apiToken: z.string().trim(),
+  hasStoredToken: z.boolean().optional().default(false),
   enable: z.boolean(),
   enable: z.boolean(),
   allowPrivateAddress: z.boolean(),
   allowPrivateAddress: z.boolean(),
   tlsVerifyMode: z.enum(['verify', 'skip', 'pin', 'mtls']),
   tlsVerifyMode: z.enum(['verify', 'skip', 'pin', 'mtls']),
@@ -77,7 +79,7 @@ export const NodeFormSchema = z.object({
   inboundTags: z.array(z.string()).nullish().transform((tags) => tags ?? []),
   inboundTags: z.array(z.string()).nullish().transform((tags) => tags ?? []),
   outboundTag: z.string().optional(),
   outboundTag: z.string().optional(),
 }).superRefine((val, ctx) => {
 }).superRefine((val, ctx) => {
-  if (val.tlsVerifyMode !== 'mtls' && val.apiToken.length === 0) {
+  if (val.tlsVerifyMode !== 'mtls' && val.apiToken.length === 0 && !val.hasStoredToken) {
     ctx.addIssue({
     ctx.addIssue({
       code: 'custom',
       code: 'custom',
       path: ['apiToken'],
       path: ['apiToken'],

+ 16 - 0
frontend/src/test/inbound-form-adapter.test.ts

@@ -155,6 +155,22 @@ describe('transportless streamSettings (wireguard / tunnel)', () => {
     }
     }
   });
   });
 
 
+  it('fills sockopt schema defaults for a stored inbound missing tproxy (#5956)', () => {
+    const values = rawInboundToFormValues({
+      port: 443,
+      protocol: 'vless',
+      settings: { clients: [] },
+      streamSettings: JSON.stringify({
+        network: 'tcp',
+        security: 'none',
+        sockopt: { tcpFastOpen: true },
+      }),
+    });
+    const stream = values.streamSettings as { sockopt?: { tproxy?: string; tcpFastOpen?: boolean } };
+    expect(stream.sockopt?.tproxy).toBe('off');
+    expect(stream.sockopt?.tcpFastOpen).toBe(true);
+  });
+
   it('still rejects a present-but-invalid network value', () => {
   it('still rejects a present-but-invalid network value', () => {
     const result = InboundFormSchema.safeParse({
     const result = InboundFormSchema.safeParse({
       port: 12345,
       port: 12345,

+ 193 - 5
internal/database/db.go

@@ -1057,7 +1057,7 @@ func runSeeders(isUsersEmpty bool) error {
 	}
 	}
 
 
 	if empty && isUsersEmpty {
 	if empty && isUsersEmpty {
-		seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "ApiTokensHash", "LegacyProxySettingsCleanup", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "ResetIpLimitNoFail2ban"}
+		seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "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
@@ -1144,6 +1144,18 @@ func runSeeders(isUsersEmpty bool) error {
 		}
 		}
 	}
 	}
 
 
+	if !slices.Contains(seedersHistory, "FreedomFinalRulesPrivateEgressBlock") {
+		if err := hardenFreedomFinalRules(); err != nil {
+			return err
+		}
+	}
+
+	if !slices.Contains(seedersHistory, "InboundRealityFinalmaskTcpStrip") {
+		if err := stripRealityFinalmaskTcp(); err != nil {
+			return err
+		}
+	}
+
 	if !slices.Contains(seedersHistory, "LegacyProxySettingsCleanup") {
 	if !slices.Contains(seedersHistory, "LegacyProxySettingsCleanup") {
 		if err := clearLegacyProxySettings(); err != nil {
 		if err := clearLegacyProxySettings(); err != nil {
 			return err
 			return err
@@ -1592,6 +1604,147 @@ func isLegacyPrivateOnlyFinalRules(v any) bool {
 	return true
 	return true
 }
 }
 
 
+func hardenFreedomFinalRules() 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: "FreedomFinalRulesPrivateEgressBlock"}).Error
+	}
+	if err != nil {
+		return err
+	}
+
+	updated, changed, rErr := rewriteFreedomFinalRulesPrivateEgress(setting.Value)
+	if rErr != nil {
+		log.Printf("FreedomFinalRulesPrivateEgressBlock: skip (invalid xrayTemplateConfig json): %v", rErr)
+		return db.Create(&model.HistoryOfSeeders{SeederName: "FreedomFinalRulesPrivateEgressBlock"}).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: "FreedomFinalRulesPrivateEgressBlock"}).Error
+	})
+}
+
+func rewriteFreedomFinalRulesPrivateEgress(raw string) (string, bool, error) {
+	if strings.TrimSpace(raw) == "" {
+		return raw, false, nil
+	}
+	var cfg map[string]any
+	if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
+		return raw, false, err
+	}
+	outbounds, ok := cfg["outbounds"].([]any)
+	if !ok {
+		return raw, false, nil
+	}
+	changed := false
+	for _, ob := range outbounds {
+		obj, ok := ob.(map[string]any)
+		if !ok {
+			continue
+		}
+		if proto, _ := obj["protocol"].(string); proto != "freedom" {
+			continue
+		}
+		settings, ok := obj["settings"].(map[string]any)
+		if !ok {
+			continue
+		}
+		if !isAllowOnlyFinalRules(settings["finalRules"]) && !isLegacyPrivateOnlyFinalRules(settings["finalRules"]) {
+			continue
+		}
+		settings["finalRules"] = []any{
+			map[string]any{"action": "block", "ip": []any{"geoip:private"}},
+			map[string]any{"action": "allow"},
+		}
+		changed = true
+	}
+	if !changed {
+		return raw, false, nil
+	}
+	out, err := json.MarshalIndent(cfg, "", "  ")
+	if err != nil {
+		return raw, false, err
+	}
+	return string(out), true, nil
+}
+
+func stripRealityFinalmaskTcp() error {
+	var inbounds []model.Inbound
+	if err := db.Find(&inbounds).Error; err != nil {
+		return err
+	}
+	return db.Transaction(func(tx *gorm.DB) error {
+		for i := range inbounds {
+			updated, changed := stripRealityFinalmaskTcpFromStream(inbounds[i].StreamSettings)
+			if !changed {
+				continue
+			}
+			if err := tx.Model(&model.Inbound{}).Where("id = ?", inbounds[i].Id).
+				Update("stream_settings", updated).Error; err != nil {
+				return err
+			}
+			log.Printf("InboundRealityFinalmaskTcpStrip: removed finalmask.tcp from REALITY inbound %d (%s)", inbounds[i].Id, inbounds[i].Tag)
+		}
+		return tx.Create(&model.HistoryOfSeeders{SeederName: "InboundRealityFinalmaskTcpStrip"}).Error
+	})
+}
+
+func stripRealityFinalmaskTcpFromStream(raw string) (string, bool) {
+	if strings.TrimSpace(raw) == "" {
+		return raw, false
+	}
+	var stream map[string]any
+	if err := json.Unmarshal([]byte(raw), &stream); err != nil {
+		return raw, false
+	}
+	if sec, _ := stream["security"].(string); sec != "reality" {
+		return raw, false
+	}
+	finalmask, ok := stream["finalmask"].(map[string]any)
+	if !ok {
+		return raw, false
+	}
+	if tcp, _ := finalmask["tcp"].([]any); len(tcp) == 0 {
+		return raw, false
+	}
+	delete(finalmask, "tcp")
+	if len(finalmask) == 0 {
+		delete(stream, "finalmask")
+	}
+	out, err := json.Marshal(stream)
+	if err != nil {
+		return raw, false
+	}
+	return string(out), true
+}
+
+func isAllowOnlyFinalRules(v any) bool {
+	rules, ok := v.([]any)
+	if !ok || len(rules) != 1 {
+		return false
+	}
+	rule, ok := rules[0].(map[string]any)
+	if !ok {
+		return false
+	}
+	if action, _ := rule["action"].(string); action != "allow" {
+		return false
+	}
+	for k := range rule {
+		if k != "action" {
+			return false
+		}
+	}
+	return true
+}
+
 func normalizeClientJSONFields(obj map[string]any) {
 func normalizeClientJSONFields(obj map[string]any) {
 	normalizeInt := func(key string) {
 	normalizeInt := func(key string) {
 		raw, exists := obj[key]
 		raw, exists := obj[key]
@@ -1776,7 +1929,7 @@ func InitDB(dbPath string) error {
 		if dsn == "" {
 		if dsn == "" {
 			return errors.New("XUI_DB_TYPE=postgres but XUI_DB_DSN is empty")
 			return errors.New("XUI_DB_TYPE=postgres but XUI_DB_DSN is empty")
 		}
 		}
-		db, err = gorm.Open(postgres.Open(dsn), c)
+		db, err = openPostgresWithRetry(dsn, c)
 		if err != nil {
 		if err != nil {
 			return err
 			return err
 		}
 		}
@@ -1787,7 +1940,8 @@ func InitDB(dbPath string) error {
 		}
 		}
 
 
 		sync := sqliteSynchronous()
 		sync := sqliteSynchronous()
-		dsn := dbPath + "?_journal_mode=DELETE&_busy_timeout=10000&_synchronous=" + sync + "&_txlock=immediate"
+		journal := sqliteJournalMode()
+		dsn := dbPath + "?_journal_mode=" + journal + "&_busy_timeout=10000&_synchronous=" + sync + "&_txlock=immediate"
 		db, err = gorm.Open(sqlite.Open(dsn), c)
 		db, err = gorm.Open(sqlite.Open(dsn), c)
 		if err != nil {
 		if err != nil {
 			return err
 			return err
@@ -1798,7 +1952,7 @@ func InitDB(dbPath string) error {
 		}
 		}
 
 
 		pragmas := []string{
 		pragmas := []string{
-			"PRAGMA journal_mode=DELETE",
+			"PRAGMA journal_mode=" + journal,
 			"PRAGMA busy_timeout=10000",
 			"PRAGMA busy_timeout=10000",
 			"PRAGMA synchronous=" + sync,
 			"PRAGMA synchronous=" + sync,
 			fmt.Sprintf("PRAGMA cache_size=-%d", envInt("XUI_DB_CACHE_MB", 32)*1024),
 			fmt.Sprintf("PRAGMA cache_size=-%d", envInt("XUI_DB_CACHE_MB", 32)*1024),
@@ -1851,6 +2005,40 @@ func normalizeApiTokenCreatedAtSeconds() error {
 		UpdateColumn("created_at", gorm.Expr("created_at / ?", 1000)).Error
 		UpdateColumn("created_at", gorm.Expr("created_at / ?", 1000)).Error
 }
 }
 
 
+// openPostgresWithRetry retries the initial PostgreSQL connection with
+// backoff so a database that starts slower than the panel (or drops out
+// briefly) does not immediately kill the process and trip systemd's
+// restart loop. Every failed attempt logs the real driver error, which
+// used to be buried behind a generic startup failure.
+func openPostgresWithRetry(dsn string, c *gorm.Config) (*gorm.DB, error) {
+	delays := []time.Duration{0, 2 * time.Second, 5 * time.Second, 10 * time.Second, 20 * time.Second, 30 * time.Second}
+	var lastErr error
+	for i, delay := range delays {
+		if delay > 0 {
+			time.Sleep(delay)
+		}
+		conn, err := gorm.Open(postgres.Open(dsn), c)
+		if err == nil {
+			if i > 0 {
+				log.Printf("postgres connection established on attempt %d/%d", i+1, len(delays))
+			}
+			return conn, nil
+		}
+		lastErr = err
+		log.Printf("postgres connection attempt %d/%d failed: %v", i+1, len(delays), err)
+	}
+	return nil, fmt.Errorf("postgres unreachable after %d attempts: %w", len(delays), lastErr)
+}
+
+func sqliteJournalMode() string {
+	switch strings.ToUpper(strings.TrimSpace(os.Getenv("XUI_DB_JOURNAL_MODE"))) {
+	case "DELETE":
+		return "DELETE"
+	default:
+		return "WAL"
+	}
+}
+
 func sqliteSynchronous() string {
 func sqliteSynchronous() string {
 	switch strings.ToUpper(strings.TrimSpace(os.Getenv("XUI_DB_SYNCHRONOUS"))) {
 	switch strings.ToUpper(strings.TrimSpace(os.Getenv("XUI_DB_SYNCHRONOUS"))) {
 	case "OFF":
 	case "OFF":
@@ -1909,7 +2097,7 @@ func Checkpoint() error {
 	if IsPostgres() {
 	if IsPostgres() {
 		return nil
 		return nil
 	}
 	}
-	return db.Exec("PRAGMA wal_checkpoint;").Error
+	return db.Exec("PRAGMA wal_checkpoint(TRUNCATE);").Error
 }
 }
 
 
 func ValidateSQLiteDB(dbPath string) error {
 func ValidateSQLiteDB(dbPath string) error {

+ 87 - 0
internal/database/freedom_finalrules_migration_test.go

@@ -0,0 +1,87 @@
+package database
+
+import (
+	"encoding/json"
+	"testing"
+)
+
+func TestRewriteFreedomFinalRulesPrivateEgress(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:        "allow-only default is hardened",
+			raw:         `{"outbounds":[{"protocol":"freedom","settings":{"domainStrategy":"AsIs","finalRules":[{"action":"allow"}]},"tag":"direct"}]}`,
+			wantChanged: true,
+			wantRules:   hardened,
+		},
+		{
+			name:        "legacy private-only allow is hardened",
+			raw:         `{"outbounds":[{"protocol":"freedom","settings":{"finalRules":[{"action":"allow","ip":["geoip:private"]}]},"tag":"direct"}]}`,
+			wantChanged: true,
+			wantRules:   hardened,
+		},
+		{
+			name:        "customized rules are preserved",
+			raw:         `{"outbounds":[{"protocol":"freedom","settings":{"finalRules":[{"action":"block","ip":["1.2.3.4"]},{"action":"allow"}]},"tag":"direct"}]}`,
+			wantChanged: false,
+		},
+		{
+			name:        "non-freedom outbounds are ignored",
+			raw:         `{"outbounds":[{"protocol":"blackhole","settings":{},"tag":"blocked"}]}`,
+			wantChanged: false,
+		},
+		{
+			name:        "empty config is untouched",
+			raw:         "",
+			wantChanged: false,
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			updated, changed, err := rewriteFreedomFinalRulesPrivateEgress(tc.raw)
+			if err != nil {
+				t.Fatalf("unexpected error: %v", err)
+			}
+			if changed != tc.wantChanged {
+				t.Fatalf("changed = %v, want %v", changed, tc.wantChanged)
+			}
+			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)
+			}
+		})
+	}
+}
+
+func TestRewriteFreedomFinalRulesPrivateEgressInvalidJSON(t *testing.T) {
+	_, changed, err := rewriteFreedomFinalRulesPrivateEgress("{not json")
+	if err == nil {
+		t.Fatal("expected a json error for malformed config")
+	}
+	if changed {
+		t.Fatal("malformed config must not report changed")
+	}
+}

+ 82 - 0
internal/database/journal_mode_test.go

@@ -0,0 +1,82 @@
+package database
+
+import (
+	"bytes"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func journalModeOf(t *testing.T) string {
+	t.Helper()
+	var mode string
+	if err := db.Raw("PRAGMA journal_mode;").Scan(&mode).Error; err != nil {
+		t.Fatalf("read journal_mode: %v", err)
+	}
+	return mode
+}
+
+func TestSqliteJournalModeDefaultsToWal(t *testing.T) {
+	t.Setenv("XUI_DB_JOURNAL_MODE", "")
+	dbDir := t.TempDir()
+	if err := InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = CloseDB() })
+
+	if got := journalModeOf(t); got != "wal" {
+		t.Fatalf("journal_mode = %q, want wal", got)
+	}
+}
+
+func TestSqliteJournalModeEnvOverrideDelete(t *testing.T) {
+	t.Setenv("XUI_DB_JOURNAL_MODE", "delete")
+	dbDir := t.TempDir()
+	if err := InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = CloseDB() })
+
+	if got := journalModeOf(t); got != "delete" {
+		t.Fatalf("journal_mode = %q, want delete", got)
+	}
+}
+
+func TestWalCheckpointMakesRawFileBackupComplete(t *testing.T) {
+	t.Setenv("XUI_DB_JOURNAL_MODE", "")
+	dbDir := t.TempDir()
+	dbPath := filepath.Join(dbDir, "x-ui.db")
+	if err := InitDB(dbPath); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = CloseDB() })
+
+	if err := db.Create(&model.Setting{Key: "walBackupProbe", Value: "42"}).Error; err != nil {
+		t.Fatalf("write setting: %v", err)
+	}
+	if err := Checkpoint(); err != nil {
+		t.Fatalf("Checkpoint: %v", err)
+	}
+
+	raw, err := os.ReadFile(dbPath)
+	if err != nil {
+		t.Fatalf("read db file: %v", err)
+	}
+	copyPath := filepath.Join(t.TempDir(), "copy.db")
+	if err := os.WriteFile(copyPath, raw, 0o600); err != nil {
+		t.Fatalf("write copy: %v", err)
+	}
+	if err := ValidateSQLiteDB(copyPath); err != nil {
+		t.Fatalf("checkpointed raw copy must be a valid sqlite db: %v", err)
+	}
+
+	dump, err := DumpSQLiteToBytes(copyPath)
+	if err != nil {
+		t.Fatalf("dump copy: %v", err)
+	}
+	if !bytes.Contains(dump, []byte("walBackupProbe")) {
+		t.Fatal("raw-file backup taken after Checkpoint must contain the latest write")
+	}
+}

+ 2 - 1
internal/database/model/model.go

@@ -706,7 +706,7 @@ type Node struct {
 	Address             string   `json:"address" form:"address" validate:"required" example:"node1.example.com"`
 	Address             string   `json:"address" form:"address" validate:"required" example:"node1.example.com"`
 	Port                int      `json:"port" form:"port" validate:"gte=1,lte=65535" example:"2053"`
 	Port                int      `json:"port" form:"port" validate:"gte=1,lte=65535" example:"2053"`
 	BasePath            string   `json:"basePath" form:"basePath" example:"/"`
 	BasePath            string   `json:"basePath" form:"basePath" example:"/"`
-	ApiToken            string   `json:"apiToken" form:"apiToken" validate:"required_unless=TlsVerifyMode mtls" example:"abcdef0123456789"`
+	ApiToken            string   `json:"-" form:"-" gorm:"column:api_token" validate:"required_unless=TlsVerifyMode mtls" example:"abcdef0123456789"`
 	Enable              bool     `json:"enable" form:"enable" gorm:"default:true" example:"true"`
 	Enable              bool     `json:"enable" form:"enable" gorm:"default:true" example:"true"`
 	AllowPrivateAddress bool     `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"`
 	AllowPrivateAddress bool     `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"`
 	TlsVerifyMode       string   `json:"tlsVerifyMode" form:"tlsVerifyMode" gorm:"column:tls_verify_mode;default:verify" validate:"omitempty,oneof=verify skip pin mtls"`
 	TlsVerifyMode       string   `json:"tlsVerifyMode" form:"tlsVerifyMode" gorm:"column:tls_verify_mode;default:verify" validate:"omitempty,oneof=verify skip pin mtls"`
@@ -1098,6 +1098,7 @@ type OutboundSubscription struct {
 	Url                  string `json:"url" form:"url"`
 	Url                  string `json:"url" form:"url"`
 	Enabled              bool   `json:"enabled" form:"enabled" gorm:"default:true"`
 	Enabled              bool   `json:"enabled" form:"enabled" gorm:"default:true"`
 	AllowPrivate         bool   `json:"allowPrivate" form:"allowPrivate" gorm:"default:false"`
 	AllowPrivate         bool   `json:"allowPrivate" form:"allowPrivate" gorm:"default:false"`
+	AllowInsecure        bool   `json:"allowInsecure" form:"allowInsecure" gorm:"default:false"`
 	TagPrefix            string `json:"tagPrefix" form:"tagPrefix"`
 	TagPrefix            string `json:"tagPrefix" form:"tagPrefix"`
 	UpdateInterval       int    `json:"updateInterval" form:"updateInterval" gorm:"default:600"` // seconds between refreshes
 	UpdateInterval       int    `json:"updateInterval" form:"updateInterval" gorm:"default:600"` // seconds between refreshes
 	Priority             int    `json:"priority" form:"priority" gorm:"default:0"`               // order among subscriptions in the merged outbounds (lower = earlier)
 	Priority             int    `json:"priority" form:"priority" gorm:"default:0"`               // order among subscriptions in the merged outbounds (lower = earlier)

+ 93 - 0
internal/database/reality_finalmask_migration_test.go

@@ -0,0 +1,93 @@
+package database
+
+import (
+	"encoding/json"
+	"testing"
+)
+
+func TestStripRealityFinalmaskTcpFromStream(t *testing.T) {
+	tests := []struct {
+		name        string
+		raw         string
+		wantChanged bool
+		wantAbsent  []string
+		wantPresent []string
+	}{
+		{
+			name:        "reality with tcp masks is stripped",
+			raw:         `{"network":"tcp","security":"reality","realitySettings":{"privateKey":"k"},"finalmask":{"tcp":[{"type":"sudoku"}]}}`,
+			wantChanged: true,
+			wantAbsent:  []string{"finalmask"},
+			wantPresent: []string{"realitySettings"},
+		},
+		{
+			name:        "reality with tcp and udp masks keeps udp",
+			raw:         `{"security":"reality","finalmask":{"tcp":[{"type":"sudoku"}],"udp":[{"type":"salt"}]}}`,
+			wantChanged: true,
+			wantAbsent:  []string{"tcp"},
+			wantPresent: []string{"udp"},
+		},
+		{
+			name:        "tls with tcp masks is untouched",
+			raw:         `{"security":"tls","finalmask":{"tcp":[{"type":"sudoku"}]}}`,
+			wantChanged: false,
+		},
+		{
+			name:        "reality without finalmask is untouched",
+			raw:         `{"security":"reality","realitySettings":{}}`,
+			wantChanged: false,
+		},
+		{
+			name:        "empty stream is untouched",
+			raw:         "",
+			wantChanged: false,
+		},
+		{
+			name:        "invalid json is untouched",
+			raw:         "{not json",
+			wantChanged: false,
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			updated, changed := stripRealityFinalmaskTcpFromStream(tc.raw)
+			if changed != tc.wantChanged {
+				t.Fatalf("changed = %v, want %v (updated: %s)", changed, tc.wantChanged, updated)
+			}
+			if !tc.wantChanged {
+				if updated != tc.raw {
+					t.Fatalf("stream mutated without change flag: %s", updated)
+				}
+				return
+			}
+			var stream map[string]any
+			if err := json.Unmarshal([]byte(updated), &stream); err != nil {
+				t.Fatalf("updated stream is not valid json: %v", err)
+			}
+			flat, _ := json.Marshal(stream)
+			for _, key := range tc.wantAbsent {
+				if containsJSONKey(stream, key) {
+					t.Fatalf("key %q should be gone: %s", key, flat)
+				}
+			}
+			for _, key := range tc.wantPresent {
+				if !containsJSONKey(stream, key) {
+					t.Fatalf("key %q should survive: %s", key, flat)
+				}
+			}
+		})
+	}
+}
+
+func containsJSONKey(m map[string]any, key string) bool {
+	if _, ok := m[key]; ok {
+		return true
+	}
+	for _, v := range m {
+		if nested, ok := v.(map[string]any); ok && containsJSONKey(nested, key) {
+			return true
+		}
+	}
+	return false
+}

+ 14 - 0
internal/sub/host_sub.go

@@ -125,6 +125,20 @@ func hostMuxOverride(ep map[string]any) string {
 // the base stream strips sockopt) and finalMask. No-op for legacy externalProxy
 // the base stream strips sockopt) and finalMask. No-op for legacy externalProxy
 // entries (which never carry these keys), so existing output is unchanged.
 // entries (which never carry these keys), so existing output is unchanged.
 func applyHostStreamOverrides(ep map[string]any, stream map[string]any) {
 func applyHostStreamOverrides(ep map[string]any, stream map[string]any) {
+	if hh, ok := ep["hostHeader"].(string); ok && hh != "" {
+		for _, key := range []string{"wsSettings", "httpupgradeSettings", "xhttpSettings"} {
+			if ts, ok := stream[key].(map[string]any); ok && ts != nil {
+				ts["host"] = hh
+			}
+		}
+	}
+	if p, ok := ep["path"].(string); ok && p != "" {
+		for _, key := range []string{"wsSettings", "httpupgradeSettings", "xhttpSettings"} {
+			if ts, ok := stream[key].(map[string]any); ok && ts != nil {
+				ts["path"] = p
+			}
+		}
+	}
 	if sp, ok := ep["sockoptParams"].(string); ok && sp != "" {
 	if sp, ok := ep["sockoptParams"].(string); ok && sp != "" {
 		var sockopt map[string]any
 		var sockopt map[string]any
 		if json.Unmarshal([]byte(sp), &sockopt) == nil && len(sockopt) > 0 {
 		if json.Unmarshal([]byte(sp), &sockopt) == nil && len(sockopt) > 0 {

+ 33 - 0
internal/sub/host_sub_test.go

@@ -271,6 +271,39 @@ func TestSub_HostAllowInsecure(t *testing.T) {
 	}
 	}
 }
 }
 
 
+// A host's Host header and path reach the Clash and JSON renderers even when
+// the inbound's own ws settings leave them empty (#5944).
+func TestSub_HostHeaderReachesClashAndJson(t *testing.T) {
+	seedSubDB(t)
+	ib := seedSubInbound(t, "s1", "hh", 4457, 1,
+		`{"network":"ws","security":"tls","wsSettings":{"path":"/"},"tlsSettings":{"serverName":"base.sni"}}`)
+	seedHost(t, &model.Host{
+		InboundId: ib.Id, SortOrder: 0, Remark: "HH", Address: "hh.cdn.com", Port: 8443, Security: "tls",
+		HostHeader: "cdn.example.com", Path: "/ws-path",
+	})
+
+	clash := NewSubClashService(false, "", NewSubService(""))
+	yaml, _, err := clash.GetClash("s1", "req.example.com")
+	if err != nil {
+		t.Fatalf("GetClash: %v", err)
+	}
+	if !strings.Contains(yaml, "Host: cdn.example.com") {
+		t.Fatalf("clash ws-opts should carry the host record's Host header:\n%s", yaml)
+	}
+	if !strings.Contains(yaml, "path: /ws-path") {
+		t.Fatalf("clash ws-opts should carry the host record's path:\n%s", yaml)
+	}
+
+	js := NewSubJsonService("", "", "", NewSubService(""))
+	out, _, err := js.GetJson("s1", "req.example.com", false)
+	if err != nil {
+		t.Fatalf("GetJson: %v", err)
+	}
+	if !strings.Contains(out, `"host": "cdn.example.com"`) && !strings.Contains(out, `"host":"cdn.example.com"`) {
+		t.Fatalf("json wsSettings should carry the host record's Host header:\n%s", out)
+	}
+}
+
 // A host's Final Mask reaches the raw share link as the fm param, merged with
 // A host's Final Mask reaches the raw share link as the fm param, merged with
 // any inbound-level mask (#5831).
 // any inbound-level mask (#5831).
 func TestSub_HostFinalMask_RawLink(t *testing.T) {
 func TestSub_HostFinalMask_RawLink(t *testing.T) {

+ 4 - 4
internal/web/controller/client.go

@@ -109,22 +109,22 @@ func (a *ClientController) get(c *gin.Context) {
 	email := c.Param("email")
 	email := c.Param("email")
 	rec, err := a.clientService.GetRecordByEmail(nil, email)
 	rec, err := a.clientService.GetRecordByEmail(nil, email)
 	if err != nil {
 	if err != nil {
-		jsonMsg(c, I18nWeb(c, "get"), err)
+		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
 		return
 		return
 	}
 	}
 	inboundIds, err := a.clientService.GetInboundIdsForRecord(rec.Id)
 	inboundIds, err := a.clientService.GetInboundIdsForRecord(rec.Id)
 	if err != nil {
 	if err != nil {
-		jsonMsg(c, I18nWeb(c, "get"), err)
+		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
 		return
 		return
 	}
 	}
 	externalLinks, err := a.clientService.GetExternalLinksForRecord(rec.Id)
 	externalLinks, err := a.clientService.GetExternalLinksForRecord(rec.Id)
 	if err != nil {
 	if err != nil {
-		jsonMsg(c, I18nWeb(c, "get"), err)
+		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
 		return
 		return
 	}
 	}
 	flow, err := a.clientService.EffectiveFlow(nil, rec.Id)
 	flow, err := a.clientService.EffectiveFlow(nil, rec.Id)
 	if err != nil {
 	if err != nil {
-		jsonMsg(c, I18nWeb(c, "get"), err)
+		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
 		return
 		return
 	}
 	}
 	rec.Flow = flow
 	rec.Flow = flow

+ 42 - 39
internal/web/controller/node.go

@@ -8,7 +8,6 @@ import (
 	"strconv"
 	"strconv"
 	"time"
 	"time"
 
 
-	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
@@ -78,7 +77,7 @@ func (a *NodeController) setMtlsTrustCA(c *gin.Context) {
 }
 }
 
 
 func (a *NodeController) list(c *gin.Context) {
 func (a *NodeController) list(c *gin.Context) {
-	nodes, err := a.nodeService.GetNodeTree()
+	nodes, err := a.nodeService.GetNodeTreeView()
 	if err != nil {
 	if err != nil {
 		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.list"), err)
 		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.list"), err)
 		return
 		return
@@ -92,7 +91,7 @@ func (a *NodeController) get(c *gin.Context) {
 		jsonMsg(c, I18nWeb(c, "get"), err)
 		jsonMsg(c, I18nWeb(c, "get"), err)
 		return
 		return
 	}
 	}
-	n, err := a.nodeService.GetById(id)
+	n, err := a.nodeService.GetViewById(id)
 	if err != nil {
 	if err != nil {
 		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
 		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
 		return
 		return
@@ -116,27 +115,32 @@ func (a *NodeController) webCert(c *gin.Context) {
 	jsonObj(c, files, nil)
 	jsonObj(c, files, nil)
 }
 }
 
 
-func (a *NodeController) ensureReachable(c *gin.Context, n *model.Node) error {
+func (a *NodeController) ensureReachable(c *gin.Context, n *service.NodeMutationRequest, id int) error {
+	runtimeNode, err := a.nodeService.RuntimeNodeFromRequest(id, n)
+	if err != nil {
+		return err
+	}
 	ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
 	ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
 	defer cancel()
 	defer cancel()
-	if _, err := a.nodeService.Probe(ctx, n); err != nil {
+	if _, err := a.nodeService.Probe(ctx, runtimeNode); err != nil {
 		return errors.New(service.FriendlyProbeError(err.Error()))
 		return errors.New(service.FriendlyProbeError(err.Error()))
 	}
 	}
 	return nil
 	return nil
 }
 }
 
 
 func (a *NodeController) add(c *gin.Context) {
 func (a *NodeController) add(c *gin.Context) {
-	n, ok := middleware.BindAndValidate[model.Node](c)
+	n, ok := middleware.BindAndValidate[service.NodeMutationRequest](c)
 	if !ok {
 	if !ok {
 		return
 		return
 	}
 	}
 	if n.OutboundTag == "" {
 	if n.OutboundTag == "" {
-		if err := a.ensureReachable(c, n); err != nil {
+		if err := a.ensureReachable(c, n, 0); err != nil {
 			jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
 			jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
 			return
 			return
 		}
 		}
 	}
 	}
-	if err := a.nodeService.Create(n); err != nil {
+	view, err := a.nodeService.CreateFromRequest(n)
+	if err != nil {
 		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
 		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
 		return
 		return
 	}
 	}
@@ -144,12 +148,12 @@ func (a *NodeController) add(c *gin.Context) {
 		if err := a.xrayService.RestartXray(false); err != nil {
 		if err := a.xrayService.RestartXray(false); err != nil {
 			logger.Warning("apply node outbound bridge failed:", err)
 			logger.Warning("apply node outbound bridge failed:", err)
 		}
 		}
-		if err := a.ensureReachable(c, n); err != nil {
+		if err := a.ensureReachable(c, n, view.Id); err != nil {
 			jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
 			jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
 			return
 			return
 		}
 		}
 	}
 	}
-	jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.add"), n, nil)
+	jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.add"), view, nil)
 }
 }
 
 
 func (a *NodeController) update(c *gin.Context) {
 func (a *NodeController) update(c *gin.Context) {
@@ -158,7 +162,7 @@ func (a *NodeController) update(c *gin.Context) {
 		jsonMsg(c, I18nWeb(c, "get"), err)
 		jsonMsg(c, I18nWeb(c, "get"), err)
 		return
 		return
 	}
 	}
-	n, ok := middleware.BindAndValidate[model.Node](c)
+	n, ok := middleware.BindAndValidate[service.NodeMutationRequest](c)
 	if !ok {
 	if !ok {
 		return
 		return
 	}
 	}
@@ -167,13 +171,13 @@ func (a *NodeController) update(c *gin.Context) {
 		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
 		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
 		return
 		return
 	}
 	}
-	if n.OutboundTag == "" && old.OutboundTag == "" {
-		if err := a.ensureReachable(c, n); err != nil {
+	if n.OutboundTag == "" && old.OutboundTag == "" && (!n.ClearApiToken || n.Enable) {
+		if err := a.ensureReachable(c, n, id); err != nil {
 			jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
 			jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
 			return
 			return
 		}
 		}
 	}
 	}
-	if err := a.nodeService.Update(id, n); err != nil {
+	if err := a.nodeService.UpdateFromRequest(id, n); err != nil {
 		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
 		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
 		return
 		return
 	}
 	}
@@ -181,7 +185,7 @@ func (a *NodeController) update(c *gin.Context) {
 		if err := a.xrayService.RestartXray(false); err != nil {
 		if err := a.xrayService.RestartXray(false); err != nil {
 			logger.Warning("apply node outbound bridge change failed:", err)
 			logger.Warning("apply node outbound bridge change failed:", err)
 		}
 		}
-		if err := a.ensureReachable(c, n); err != nil {
+		if err := a.ensureReachable(c, n, id); err != nil {
 			jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
 			jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
 			return
 			return
 		}
 		}
@@ -233,58 +237,57 @@ func (a *NodeController) setEnable(c *gin.Context) {
 }
 }
 
 
 func (a *NodeController) inbounds(c *gin.Context) {
 func (a *NodeController) inbounds(c *gin.Context) {
-	n := &model.Node{}
-	if err := c.ShouldBind(n); err != nil {
+	n, ok := middleware.BindAndValidate[service.NodeMutationRequest](c)
+	if !ok {
+		return
+	}
+	runtimeNode, err := a.nodeService.RuntimeNodeFromRequest(n.Id, n)
+	if err != nil {
 		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
 		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
 		return
 		return
 	}
 	}
 	ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
 	ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
 	defer cancel()
 	defer cancel()
-	options, err := a.nodeService.GetRemoteInboundOptions(ctx, n)
+	options, err := a.nodeService.GetRemoteInboundOptions(ctx, runtimeNode)
 	jsonObj(c, options, err)
 	jsonObj(c, options, err)
 }
 }
 
 
 func (a *NodeController) test(c *gin.Context) {
 func (a *NodeController) test(c *gin.Context) {
-	n := &model.Node{}
-	if err := c.ShouldBind(n); err != nil {
-		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err)
+	n, ok := middleware.BindAndValidate[service.NodeMutationRequest](c)
+	if !ok {
 		return
 		return
 	}
 	}
-	if n.Scheme == "" {
-		n.Scheme = "https"
-	}
-	if n.BasePath == "" {
-		n.BasePath = "/"
+	runtimeNode, err := a.nodeService.RuntimeNodeFromRequest(n.Id, n)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err)
+		return
 	}
 	}
 
 
 	ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
 	ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
 	defer cancel()
 	defer cancel()
 	var patch service.HeartbeatPatch
 	var patch service.HeartbeatPatch
-	var err error
-	if n.OutboundTag != "" {
-		patch, err = a.nodeService.ProbeWithOutbound(ctx, n, n.OutboundTag)
+	if runtimeNode.OutboundTag != "" {
+		patch, err = a.nodeService.ProbeWithOutbound(ctx, runtimeNode, runtimeNode.OutboundTag)
 	} else {
 	} else {
-		patch, err = a.nodeService.Probe(ctx, n)
+		patch, err = a.nodeService.Probe(ctx, runtimeNode)
 	}
 	}
 	jsonObj(c, patch.ToUI(err == nil), nil)
 	jsonObj(c, patch.ToUI(err == nil), nil)
 }
 }
 
 
 func (a *NodeController) certFingerprint(c *gin.Context) {
 func (a *NodeController) certFingerprint(c *gin.Context) {
-	n := &model.Node{}
-	if err := c.ShouldBind(n); err != nil {
-		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err)
+	n, ok := middleware.BindAndValidate[service.NodeMutationRequest](c)
+	if !ok {
 		return
 		return
 	}
 	}
-	if n.Scheme == "" {
-		n.Scheme = "https"
-	}
-	if n.BasePath == "" {
-		n.BasePath = "/"
+	runtimeNode, err := a.nodeService.NodeFromRequestForCertificate(n)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err)
+		return
 	}
 	}
 
 
 	ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
 	ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
 	defer cancel()
 	defer cancel()
-	fp, err := a.nodeService.FetchCertFingerprint(ctx, n)
+	fp, err := a.nodeService.FetchCertFingerprint(ctx, runtimeNode)
 	if err != nil {
 	if err != nil {
 		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err)
 		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err)
 		return
 		return

+ 192 - 0
internal/web/controller/node_credentials_writeonly_test.go

@@ -0,0 +1,192 @@
+package controller
+
+import (
+	"encoding/json"
+	"net"
+	"net/http"
+	"net/http/httptest"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"testing"
+
+	"github.com/gin-gonic/gin"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/locale"
+)
+
+func newNodeCredentialTestEngine(t *testing.T) *gin.Engine {
+	t.Helper()
+	gin.SetMode(gin.TestMode)
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
+	engine := gin.New()
+	engine.Use(func(c *gin.Context) {
+		c.Set("I18n", func(_ locale.I18nType, key string, _ ...string) string { return key })
+		c.Next()
+	})
+	NewNodeController(engine.Group("/panel/api/nodes"))
+	return engine
+}
+
+func TestNodeControllerResponsesDoNotLeakApiToken(t *testing.T) {
+	engine := newNodeCredentialTestEngine(t)
+	if err := database.GetDB().Create(&model.Node{
+		Name:     "stored-node",
+		Scheme:   "https",
+		Address:  "example.com",
+		Port:     2053,
+		BasePath: "/",
+		ApiToken: "stored-secret-token",
+		Enable:   true,
+	}).Error; err != nil {
+		t.Fatalf("seed node: %v", err)
+	}
+
+	for _, path := range []string{"/panel/api/nodes/list", "/panel/api/nodes/get/1"} {
+		w := httptest.NewRecorder()
+		engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
+		if w.Code != http.StatusOK {
+			t.Fatalf("%s status = %d body=%s", path, w.Code, w.Body.String())
+		}
+		body := w.Body.String()
+		if strings.Contains(body, "stored-secret-token") || strings.Contains(body, "apiToken") {
+			t.Fatalf("%s leaked api token: %s", path, body)
+		}
+		if !strings.Contains(body, `"hasApiToken":true`) {
+			t.Fatalf("%s did not expose credential presence: %s", path, body)
+		}
+	}
+}
+
+func TestNodeControllerAddAcceptsTokenButReturnsView(t *testing.T) {
+	engine := newNodeCredentialTestEngine(t)
+
+	remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.URL.Path != "/panel/api/server/status" {
+			t.Fatalf("unexpected path: %s", r.URL.Path)
+		}
+		if got := r.Header.Get("Authorization"); got != "Bearer input-secret-token" {
+			t.Fatalf("Authorization = %q", got)
+		}
+		w.Header().Set("Content-Type", "application/json")
+		_, _ = w.Write([]byte(`{"success":true,"obj":{"cpu":1,"mem":{"current":1,"total":2},"xray":{"version":"1","state":"running"},"panelVersion":"v3.4.1","panelGuid":"guid","uptime":7,"netIO":{"up":3,"down":4}}}`))
+	}))
+	defer remote.Close()
+	host, portString, err := net.SplitHostPort(strings.TrimPrefix(remote.URL, "http://"))
+	if err != nil {
+		t.Fatalf("split remote addr: %v", err)
+	}
+	port, err := strconv.Atoi(portString)
+	if err != nil {
+		t.Fatalf("parse remote port: %v", err)
+	}
+
+	payload := map[string]any{
+		"name":                "added-node",
+		"scheme":              "http",
+		"address":             host,
+		"port":                port,
+		"basePath":            "/",
+		"apiToken":            "input-secret-token",
+		"enable":              true,
+		"allowPrivateAddress": true,
+	}
+	raw, _ := json.Marshal(payload)
+	w := httptest.NewRecorder()
+	req := httptest.NewRequest(http.MethodPost, "/panel/api/nodes/add", strings.NewReader(string(raw)))
+	req.Header.Set("Content-Type", "application/json")
+	engine.ServeHTTP(w, req)
+	if w.Code != http.StatusOK {
+		t.Fatalf("add status = %d body=%s", w.Code, w.Body.String())
+	}
+	body := w.Body.String()
+	if strings.Contains(body, "input-secret-token") || strings.Contains(body, "apiToken") {
+		t.Fatalf("add response leaked api token: %s", body)
+	}
+	if !strings.Contains(body, `"hasApiToken":true`) {
+		t.Fatalf("add response did not expose credential presence: %s", body)
+	}
+
+	var stored model.Node
+	if err := database.GetDB().Where("name = ?", "added-node").First(&stored).Error; err != nil {
+		t.Fatalf("load stored node: %v", err)
+	}
+	if stored.ApiToken != "input-secret-token" {
+		t.Fatalf("stored token = %q, want input-secret-token", stored.ApiToken)
+	}
+}
+
+func TestNodeControllerUpdateBlankApiTokenKeepsStoredToken(t *testing.T) {
+	engine := newNodeCredentialTestEngine(t)
+
+	remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.URL.Path != "/panel/api/server/status" {
+			t.Fatalf("unexpected path: %s", r.URL.Path)
+		}
+		if got := r.Header.Get("Authorization"); got != "Bearer stored-secret-token" {
+			t.Fatalf("Authorization = %q", got)
+		}
+		w.Header().Set("Content-Type", "application/json")
+		_, _ = w.Write([]byte(`{"success":true,"obj":{"cpu":1,"mem":{"current":1,"total":2},"xray":{"version":"1","state":"running"},"panelVersion":"v3.4.1","panelGuid":"guid","uptime":7,"netIO":{"up":3,"down":4}}}`))
+	}))
+	defer remote.Close()
+	host, portString, err := net.SplitHostPort(strings.TrimPrefix(remote.URL, "http://"))
+	if err != nil {
+		t.Fatalf("split remote addr: %v", err)
+	}
+	port, err := strconv.Atoi(portString)
+	if err != nil {
+		t.Fatalf("parse remote port: %v", err)
+	}
+	node := &model.Node{
+		Name:                "stored-node",
+		Scheme:              "http",
+		Address:             host,
+		Port:                port,
+		BasePath:            "/",
+		ApiToken:            "stored-secret-token",
+		Enable:              true,
+		AllowPrivateAddress: true,
+	}
+	if err := database.GetDB().Create(node).Error; err != nil {
+		t.Fatalf("seed node: %v", err)
+	}
+
+	payload := map[string]any{
+		"name":                "stored-node-renamed",
+		"scheme":              "http",
+		"address":             host,
+		"port":                port,
+		"basePath":            "/",
+		"apiToken":            "",
+		"enable":              true,
+		"allowPrivateAddress": true,
+	}
+	raw, _ := json.Marshal(payload)
+	w := httptest.NewRecorder()
+	req := httptest.NewRequest(http.MethodPost, "/panel/api/nodes/update/"+strconv.Itoa(node.Id), strings.NewReader(string(raw)))
+	req.Header.Set("Content-Type", "application/json")
+	engine.ServeHTTP(w, req)
+	if w.Code != http.StatusOK {
+		t.Fatalf("update status = %d body=%s", w.Code, w.Body.String())
+	}
+
+	var stored model.Node
+	if err := database.GetDB().Where("id = ?", node.Id).First(&stored).Error; err != nil {
+		t.Fatalf("load stored node: %v", err)
+	}
+	if stored.ApiToken != "stored-secret-token" {
+		t.Fatalf("blank update changed token to %q", stored.ApiToken)
+	}
+	if stored.Name != "stored-node-renamed" {
+		t.Fatalf("stored name = %q, want stored-node-renamed", stored.Name)
+	}
+}

+ 2 - 1
internal/web/controller/server.go

@@ -463,7 +463,8 @@ func (a *ServerController) getRemoteCertHash(c *gin.Context) {
 // scanRealityTarget runs a live TLS 1.3 probe against the candidate REALITY
 // scanRealityTarget runs a live TLS 1.3 probe against the candidate REALITY
 // target and returns a structured feasibility verdict plus the cert SAN names.
 // target and returns a structured feasibility verdict plus the cert SAN names.
 func (a *ServerController) scanRealityTarget(c *gin.Context) {
 func (a *ServerController) scanRealityTarget(c *gin.Context) {
-	res, err := a.serverService.ScanRealityTarget(c.PostForm("target"))
+	xver, _ := strconv.Atoi(c.PostForm("xver"))
+	res, err := a.serverService.ScanRealityTarget(c.PostForm("target"), xver)
 	if err != nil {
 	if err != nil {
 		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.scanRealityTargetError"), err)
 		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.scanRealityTargetError"), err)
 		return
 		return

+ 6 - 3
internal/web/controller/xray_setting.go

@@ -408,6 +408,7 @@ func (a *XraySettingController) createOutboundSub(c *gin.Context) {
 	prefix := c.PostForm("tagPrefix")
 	prefix := c.PostForm("tagPrefix")
 	enabled := c.PostForm("enabled") != "false"
 	enabled := c.PostForm("enabled") != "false"
 	allowPrivate := c.PostForm("allowPrivate") == "true"
 	allowPrivate := c.PostForm("allowPrivate") == "true"
+	allowInsecure := c.PostForm("allowInsecure") == "true"
 	prepend := c.PostForm("prepend") == "true"
 	prepend := c.PostForm("prepend") == "true"
 	intervalStr := c.PostForm("updateInterval")
 	intervalStr := c.PostForm("updateInterval")
 	interval := 600
 	interval := 600
@@ -416,7 +417,7 @@ func (a *XraySettingController) createOutboundSub(c *gin.Context) {
 			interval = v
 			interval = v
 		}
 		}
 	}
 	}
-	sub, err := a.OutboundSubscriptionService.Create(remark, rawURL, prefix, enabled, interval, allowPrivate, prepend)
+	sub, err := a.OutboundSubscriptionService.Create(remark, rawURL, prefix, enabled, interval, allowPrivate, prepend, allowInsecure)
 	if err != nil {
 	if err != nil {
 		jsonMsg(c, "Failed to create outbound subscription", err)
 		jsonMsg(c, "Failed to create outbound subscription", err)
 		return
 		return
@@ -436,6 +437,7 @@ func (a *XraySettingController) updateOutboundSub(c *gin.Context) {
 	prefix := c.PostForm("tagPrefix")
 	prefix := c.PostForm("tagPrefix")
 	enabled := c.PostForm("enabled") != "false"
 	enabled := c.PostForm("enabled") != "false"
 	allowPrivate := c.PostForm("allowPrivate") == "true"
 	allowPrivate := c.PostForm("allowPrivate") == "true"
+	allowInsecure := c.PostForm("allowInsecure") == "true"
 	prepend := c.PostForm("prepend") == "true"
 	prepend := c.PostForm("prepend") == "true"
 	intervalStr := c.PostForm("updateInterval")
 	intervalStr := c.PostForm("updateInterval")
 	interval := 600
 	interval := 600
@@ -444,7 +446,7 @@ func (a *XraySettingController) updateOutboundSub(c *gin.Context) {
 			interval = v
 			interval = v
 		}
 		}
 	}
 	}
-	if err := a.OutboundSubscriptionService.Update(subID, remark, rawURL, prefix, enabled, interval, allowPrivate, prepend); err != nil {
+	if err := a.OutboundSubscriptionService.Update(subID, remark, rawURL, prefix, enabled, interval, allowPrivate, prepend, allowInsecure); err != nil {
 		jsonMsg(c, "Failed to update outbound subscription", err)
 		jsonMsg(c, "Failed to update outbound subscription", err)
 		return
 		return
 	}
 	}
@@ -511,12 +513,13 @@ func (a *XraySettingController) parseOutboundSubURL(c *gin.Context) {
 		return
 		return
 	}
 	}
 	allowPrivate := c.PostForm("allowPrivate") == "true"
 	allowPrivate := c.PostForm("allowPrivate") == "true"
+	allowInsecure := c.PostForm("allowInsecure") == "true"
 	// Use a throw-away service instance; it only needs the settingService for proxy.
 	// Use a throw-away service instance; it only needs the settingService for proxy.
 	svc := service.OutboundSubscriptionService{}
 	svc := service.OutboundSubscriptionService{}
 	// We don't have a direct "fetch once" that returns without storing, so we
 	// We don't have a direct "fetch once" that returns without storing, so we
 	// temporarily create a disabled row, refresh it, then delete. Cleaner would
 	// temporarily create a disabled row, refresh it, then delete. Cleaner would
 	// be to expose a pure ParseURL on the service, but this keeps the surface small.
 	// be to expose a pure ParseURL on the service, but this keeps the surface small.
-	tmp, err := svc.Create("preview", rawURL, "", false, 600, allowPrivate, false)
+	tmp, err := svc.Create("preview", rawURL, "", false, 600, allowPrivate, false, allowInsecure)
 	if err != nil {
 	if err != nil {
 		jsonMsg(c, "Failed to preview subscription", err)
 		jsonMsg(c, "Failed to preview subscription", err)
 		return
 		return

+ 1 - 0
internal/web/service/config.json

@@ -33,6 +33,7 @@
       "settings": {
       "settings": {
         "domainStrategy": "AsIs",
         "domainStrategy": "AsIs",
         "finalRules": [
         "finalRules": [
+          { "action": "block", "ip": ["geoip:private"] },
           { "action": "allow" }
           { "action": "allow" }
         ]
         ]
       },
       },

+ 48 - 29
internal/web/service/inbound.go

@@ -1418,45 +1418,64 @@ func (s *InboundService) buildRuntimeInboundForAPI(tx *gorm.DB, inbound *model.I
 		return nil, err
 		return nil, err
 	}
 	}
 
 
-	clients, ok := settings["clients"].([]any)
-	if !ok {
-		return &runtimeInbound, nil
-	}
+	mutated := false
+	if clients, ok := settings["clients"].([]any); ok {
+		var clientStats []xray.ClientTraffic
+		err := tx.Model(xray.ClientTraffic{}).
+			Where("inbound_id = ?", inbound.Id).
+			Select("email", "enable").
+			Find(&clientStats).Error
+		if err != nil {
+			return nil, err
+		}
 
 
-	var clientStats []xray.ClientTraffic
-	err := tx.Model(xray.ClientTraffic{}).
-		Where("inbound_id = ?", inbound.Id).
-		Select("email", "enable").
-		Find(&clientStats).Error
-	if err != nil {
-		return nil, err
-	}
+		enableMap := make(map[string]bool, len(clientStats))
+		for _, clientTraffic := range clientStats {
+			enableMap[clientTraffic.Email] = clientTraffic.Enable
+		}
 
 
-	enableMap := make(map[string]bool, len(clientStats))
-	for _, clientTraffic := range clientStats {
-		enableMap[clientTraffic.Email] = clientTraffic.Enable
-	}
+		finalClients := make([]any, 0, len(clients))
+		for _, client := range clients {
+			c, ok := client.(map[string]any)
+			if !ok {
+				continue
+			}
 
 
-	finalClients := make([]any, 0, len(clients))
-	for _, client := range clients {
-		c, ok := client.(map[string]any)
-		if !ok {
-			continue
-		}
+			email, _ := c["email"].(string)
+			if enable, exists := enableMap[email]; exists && !enable {
+				continue
+			}
 
 
-		email, _ := c["email"].(string)
-		if enable, exists := enableMap[email]; exists && !enable {
-			continue
+			if manualEnable, ok := c["enable"].(bool); ok && !manualEnable {
+				continue
+			}
+
+			finalClients = append(finalClients, c)
 		}
 		}
 
 
-		if manualEnable, ok := c["enable"].(bool); ok && !manualEnable {
-			continue
+		settings["clients"] = finalClients
+		mutated = true
+	}
+
+	if inboundCanHostFallbacks(inbound) {
+		fallbacks, fbErr := s.fallbackService.BuildFallbacksJSON(tx, inbound.Id)
+		if fbErr != nil {
+			return nil, fbErr
 		}
 		}
+		if len(fallbacks) > 0 {
+			generic := make([]any, 0, len(fallbacks))
+			for _, f := range fallbacks {
+				generic = append(generic, f)
+			}
+			settings["fallbacks"] = generic
+			mutated = true
+		}
+	}
 
 
-		finalClients = append(finalClients, c)
+	if !mutated {
+		return &runtimeInbound, nil
 	}
 	}
 
 
-	settings["clients"] = finalClients
 	modifiedSettings, err := json.MarshalIndent(settings, "", "  ")
 	modifiedSettings, err := json.MarshalIndent(settings, "", "  ")
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err

+ 60 - 0
internal/web/service/inbound_client_move_test.go

@@ -0,0 +1,60 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+// TestGetClientByEmail_AfterMoveBetweenInbounds is the #6059 regression: a
+// client moved to another inbound keeps a client_traffics row pointing at the
+// old inbound (which still exists), so email lookups used to fail with
+// "Client Not Found In Inbound For Email" and the Telegram bot could not build
+// links or QR codes for the client anymore.
+func TestGetClientByEmail_AfterMoveBetweenInbounds(t *testing.T) {
+	setupBulkDB(t)
+	svc := &InboundService{}
+	db := database.GetDB()
+
+	oldClients := []model.Client{
+		{Email: "stay@x", ID: "11111111-1111-1111-1111-111111111111", Enable: true},
+	}
+	movedClients := []model.Client{
+		{Email: "moved@x", ID: "22222222-2222-2222-2222-222222222222", Enable: true},
+	}
+
+	oldIb := mkInbound(t, 30101, model.VLESS, clientsSettings(t, oldClients))
+	newIb := mkInbound(t, 30102, model.VLESS, clientsSettings(t, movedClients))
+	if err := svc.clientService.SyncInbound(nil, oldIb.Id, oldClients); err != nil {
+		t.Fatalf("SyncInbound old: %v", err)
+	}
+	if err := svc.clientService.SyncInbound(nil, newIb.Id, movedClients); err != nil {
+		t.Fatalf("SyncInbound new: %v", err)
+	}
+
+	stale := xray.ClientTraffic{InboundId: oldIb.Id, Email: "moved@x", Enable: true, Up: 5, Down: 7}
+	if err := db.Create(&stale).Error; err != nil {
+		t.Fatalf("seed stale traffic row: %v", err)
+	}
+
+	traffic, inbound, err := svc.GetClientInboundByEmail("moved@x")
+	if err != nil {
+		t.Fatalf("GetClientInboundByEmail: %v", err)
+	}
+	if traffic == nil || traffic.Email != "moved@x" {
+		t.Fatalf("traffic = %+v, want the moved@x row", traffic)
+	}
+	if inbound == nil || inbound.Id != newIb.Id {
+		t.Fatalf("inbound = %+v, want the new inbound %d", inbound, newIb.Id)
+	}
+
+	_, client, err := svc.GetClientByEmail("moved@x")
+	if err != nil {
+		t.Fatalf("GetClientByEmail: %v", err)
+	}
+	if client == nil || client.Email != "moved@x" {
+		t.Fatalf("client = %+v, want moved@x", client)
+	}
+}

+ 30 - 0
internal/web/service/inbound_clients.go

@@ -413,9 +413,39 @@ func (s *InboundService) GetClientInboundByEmail(email string) (traffic *xray.Cl
 			inbound, err = s.GetInbound(ids[0])
 			inbound, err = s.GetInbound(ids[0])
 		}
 		}
 	}
 	}
+	if err == nil && inbound != nil && !s.inboundHasClientEmail(inbound, email) {
+		// The pointed-at inbound still exists but no longer carries the client —
+		// the client was moved to another inbound (#6059). Resolve through the
+		// client_inbounds link to the inbound that actually hosts it now.
+		ids, idErr := s.clientService.GetInboundIdsForEmail(db, email)
+		if idErr == nil {
+			for _, id := range ids {
+				if id == inbound.Id {
+					continue
+				}
+				if other, oErr := s.GetInbound(id); oErr == nil && s.inboundHasClientEmail(other, email) {
+					inbound = other
+					break
+				}
+			}
+		}
+	}
 	return traffic, inbound, err
 	return traffic, inbound, err
 }
 }
 
 
+func (s *InboundService) inboundHasClientEmail(inbound *model.Inbound, email string) bool {
+	clients, err := s.GetClients(inbound)
+	if err != nil {
+		return false
+	}
+	for _, client := range clients {
+		if client.Email == email {
+			return true
+		}
+	}
+	return false
+}
+
 func (s *InboundService) GetClientByEmail(clientEmail string) (*xray.ClientTraffic, *model.Client, error) {
 func (s *InboundService) GetClientByEmail(clientEmail string) (*xray.ClientTraffic, *model.Client, error) {
 	traffic, inbound, err := s.GetClientInboundByEmail(clientEmail)
 	traffic, inbound, err := s.GetClientInboundByEmail(clientEmail)
 	if err != nil {
 	if err != nil {

+ 78 - 0
internal/web/service/inbound_fallback_runtime_test.go

@@ -0,0 +1,78 @@
+package service
+
+import (
+	"encoding/json"
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// TestBuildRuntimeInboundForAPI_InjectsFallbacks is the #5963 regression: the
+// runtime inbound sent to nodes never carried the inbound_fallbacks rows, so
+// fallbacks configured on the master silently vanished from every node.
+func TestBuildRuntimeInboundForAPI_InjectsFallbacks(t *testing.T) {
+	setupBulkDB(t)
+	svc := &InboundService{}
+	db := database.GetDB()
+
+	clients := []model.Client{
+		{Email: "fb@x", ID: "11111111-1111-1111-1111-111111111111", Enable: true},
+	}
+	master := mkInbound(t, 30201, model.VLESS, clientsSettings(t, clients))
+	master.StreamSettings = `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"s"}}`
+	if err := db.Save(master).Error; err != nil {
+		t.Fatalf("save master stream: %v", err)
+	}
+
+	fb := model.InboundFallback{MasterId: master.Id, ChildId: 0, Dest: "8081", Alpn: "h2", Path: "/fb", Xver: 1}
+	if err := db.Create(&fb).Error; err != nil {
+		t.Fatalf("seed fallback: %v", err)
+	}
+
+	runtimeIb, err := svc.buildRuntimeInboundForAPI(db, master)
+	if err != nil {
+		t.Fatalf("buildRuntimeInboundForAPI: %v", err)
+	}
+
+	var settings map[string]any
+	if err := json.Unmarshal([]byte(runtimeIb.Settings), &settings); err != nil {
+		t.Fatalf("runtime settings not valid json: %v", err)
+	}
+	fallbacks, ok := settings["fallbacks"].([]any)
+	if !ok || len(fallbacks) != 1 {
+		t.Fatalf("runtime settings must carry the fallback, got: %s", runtimeIb.Settings)
+	}
+	if !strings.Contains(runtimeIb.Settings, `"dest"`) || !strings.Contains(runtimeIb.Settings, "8081") {
+		t.Fatalf("fallback dest missing from runtime settings: %s", runtimeIb.Settings)
+	}
+}
+
+// A non-fallback-capable inbound (ws transport) must stay untouched.
+func TestBuildRuntimeInboundForAPI_NoFallbacksOnWsInbound(t *testing.T) {
+	setupBulkDB(t)
+	svc := &InboundService{}
+	db := database.GetDB()
+
+	clients := []model.Client{
+		{Email: "ws@x", ID: "22222222-2222-2222-2222-222222222222", Enable: true},
+	}
+	ib := mkInbound(t, 30202, model.VLESS, clientsSettings(t, clients))
+	ib.StreamSettings = `{"network":"ws","security":"none"}`
+	if err := db.Save(ib).Error; err != nil {
+		t.Fatalf("save stream: %v", err)
+	}
+	fb := model.InboundFallback{MasterId: ib.Id, Dest: "8082"}
+	if err := db.Create(&fb).Error; err != nil {
+		t.Fatalf("seed fallback: %v", err)
+	}
+
+	runtimeIb, err := svc.buildRuntimeInboundForAPI(db, ib)
+	if err != nil {
+		t.Fatalf("buildRuntimeInboundForAPI: %v", err)
+	}
+	if strings.Contains(runtimeIb.Settings, "fallbacks") {
+		t.Fatalf("ws inbound must not receive fallbacks: %s", runtimeIb.Settings)
+	}
+}

+ 8 - 1
internal/web/service/inbound_node.go

@@ -125,7 +125,14 @@ func (s *InboundService) ReconcileNode(ctx context.Context, rt *runtime.Remote,
 				}
 				}
 			}
 			}
 		}
 		}
-		if _, err := rt.ReconcileInbound(ctx, ib, existsOnNode); err != nil {
+		// Reconcile with the same runtime-built payload interactive pushes
+		// send (disabled clients filtered, settings.fallbacks injected) so
+		// fingerprints line up and fallback edits actually reach the node.
+		runtimeIb := ib
+		if built, bErr := s.buildRuntimeInboundForAPI(db, ib); bErr == nil {
+			runtimeIb = built
+		}
+		if _, err := rt.ReconcileInbound(ctx, runtimeIb, existsOnNode); err != nil {
 			errs = append(errs, fmt.Errorf("reconcile inbound %q: %w", ib.Tag, err))
 			errs = append(errs, fmt.Errorf("reconcile inbound %q: %w", ib.Tag, err))
 		}
 		}
 	}
 	}

+ 123 - 0
internal/web/service/node.go

@@ -336,6 +336,14 @@ func (s *NodeService) GetById(id int) (*model.Node, error) {
 	return n, nil
 	return n, nil
 }
 }
 
 
+func (s *NodeService) GetViewById(id int) (*NodeView, error) {
+	n, err := s.GetById(id)
+	if err != nil {
+		return nil, err
+	}
+	return toNodeView(n), nil
+}
+
 // NodeExists reports whether a node with the given id exists on this panel.
 // NodeExists reports whether a node with the given id exists on this panel.
 // Used to drop stale, cross-panel node references on inbound import. A Count
 // Used to drop stale, cross-panel node references on inbound import. A Count
 // query distinguishes "no such node" (count 0, no error) from a real DB error.
 // query distinguishes "no such node" (count 0, no error) from a real DB error.
@@ -424,6 +432,17 @@ func (s *NodeService) Create(n *model.Node) error {
 	return db.Create(n).Error
 	return db.Create(n).Error
 }
 }
 
 
+func (s *NodeService) CreateFromRequest(req *NodeMutationRequest) (*NodeView, error) {
+	if err := req.validateCredentials(true); err != nil {
+		return nil, err
+	}
+	n := req.toNode()
+	if err := s.Create(n); err != nil {
+		return nil, err
+	}
+	return toNodeView(n), nil
+}
+
 func (s *NodeService) Update(id int, in *model.Node) error {
 func (s *NodeService) Update(id int, in *model.Node) error {
 	if err := s.normalize(in); err != nil {
 	if err := s.normalize(in); err != nil {
 		return err
 		return err
@@ -467,6 +486,110 @@ func (s *NodeService) Update(id int, in *model.Node) error {
 	return nil
 	return nil
 }
 }
 
 
+func (s *NodeService) UpdateFromRequest(id int, req *NodeMutationRequest) error {
+	if err := req.validateCredentials(false); err != nil {
+		return err
+	}
+	in := req.toNode()
+	if err := s.normalize(in); err != nil {
+		return err
+	}
+	inboundTagsJSON, err := json.Marshal(in.InboundTags)
+	if err != nil {
+		return err
+	}
+	db := database.GetDB()
+	existing := &model.Node{}
+	if err := db.Where("id = ?", id).First(existing).Error; err != nil {
+		return err
+	}
+	apiToken := existing.ApiToken
+	switch {
+	case req.ClearApiToken:
+		apiToken = ""
+	case req.ApiToken != nil:
+		apiToken = *req.ApiToken
+	}
+	if apiToken == "" && in.Enable && in.TlsVerifyMode != "mtls" {
+		return common.NewError("apiToken is required unless mtls is enabled")
+	}
+	updates := map[string]any{
+		"name":                  in.Name,
+		"remark":                in.Remark,
+		"scheme":                in.Scheme,
+		"address":               in.Address,
+		"port":                  in.Port,
+		"base_path":             in.BasePath,
+		"api_token":             apiToken,
+		"enable":                in.Enable,
+		"allow_private_address": in.AllowPrivateAddress,
+		"tls_verify_mode":       in.TlsVerifyMode,
+		"pinned_cert_sha256":    in.PinnedCertSha256,
+		"inbound_sync_mode":     in.InboundSyncMode,
+		"inbound_tags":          string(inboundTagsJSON),
+		"outbound_tag":          in.OutboundTag,
+	}
+	if err := db.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil {
+		return err
+	}
+	if dErr := s.MarkNodeDirty(id); dErr != nil {
+		logger.Warning("mark node dirty after update failed:", dErr)
+	}
+	if mgr := runtime.GetManager(); mgr != nil {
+		mgr.InvalidateNode(id)
+	}
+	return nil
+}
+
+func (s *NodeService) RuntimeNodeFromRequest(id int, req *NodeMutationRequest) (*model.Node, error) {
+	if err := req.validateCredentials(id == 0); err != nil {
+		return nil, err
+	}
+	var n *model.Node
+	if id > 0 {
+		existing, err := s.GetById(id)
+		if err != nil {
+			return nil, err
+		}
+		n = existing
+	} else {
+		n = &model.Node{}
+	}
+	overlay := req.toNode()
+	overlay.Id = id
+	if req.ApiToken == nil {
+		overlay.ApiToken = n.ApiToken
+	}
+	if req.ClearApiToken {
+		overlay.ApiToken = ""
+	}
+	*n = *overlay
+	if err := s.normalize(n); err != nil {
+		return nil, err
+	}
+	if n.ApiToken == "" && n.Enable && n.TlsVerifyMode != "mtls" {
+		return nil, common.NewError("apiToken is required unless mtls is enabled")
+	}
+	return n, nil
+}
+
+func (s *NodeService) NodeFromRequestForCertificate(req *NodeMutationRequest) (*model.Node, error) {
+	if req == nil {
+		return nil, common.NewError("node request is required")
+	}
+	n := req.toNode()
+	if n.Scheme == "" {
+		n.Scheme = "https"
+	}
+	if n.BasePath == "" {
+		n.BasePath = "/"
+	}
+	if err := s.normalize(n); err != nil {
+		return nil, err
+	}
+	return n, nil
+}
+
 func (s *NodeService) GetRemoteInboundOptions(ctx context.Context, n *model.Node) ([]runtime.RemoteInboundOption, error) {
 func (s *NodeService) GetRemoteInboundOptions(ctx context.Context, n *model.Node) ([]runtime.RemoteInboundOption, error) {
 	if err := s.normalize(n); err != nil {
 	if err := s.normalize(n); err != nil {
 		return nil, err
 		return nil, err

+ 186 - 0
internal/web/service/node_contract.go

@@ -0,0 +1,186 @@
+package service
+
+import (
+	"strings"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
+)
+
+// NodeView is the browser/API read contract for nodes. Credentials are
+// write-only: responses expose only whether a node has a token configured.
+type NodeView struct {
+	Id                  int      `json:"id" example:"1"`
+	Name                string   `json:"name" example:"edge-1"`
+	Remark              string   `json:"remark" example:"Primary edge"`
+	Scheme              string   `json:"scheme" example:"https"`
+	Address             string   `json:"address" example:"node.example.com"`
+	Port                int      `json:"port" example:"2053"`
+	BasePath            string   `json:"basePath" example:"/"`
+	HasApiToken         bool     `json:"hasApiToken" example:"true"`
+	Enable              bool     `json:"enable" example:"true"`
+	AllowPrivateAddress bool     `json:"allowPrivateAddress" example:"false"`
+	TlsVerifyMode       string   `json:"tlsVerifyMode" example:"verify"`
+	PinnedCertSha256    string   `json:"pinnedCertSha256" example:""`
+	InboundSyncMode     string   `json:"inboundSyncMode" example:"all"`
+	InboundTags         []string `json:"inboundTags" example:"[\"in-443-tcp\"]"`
+	OutboundTag         string   `json:"outboundTag" example:"direct"`
+	Guid                string   `json:"guid" example:"node-guid"`
+	Status              string   `json:"status" example:"online"`
+	LastHeartbeat       int64    `json:"lastHeartbeat" example:"1700000000"`
+	LatencyMs           int      `json:"latencyMs" example:"42"`
+	XrayVersion         string   `json:"xrayVersion" example:"25.10.31"`
+	PanelVersion        string   `json:"panelVersion" example:"v3.x.x"`
+	CpuPct              float64  `json:"cpuPct" example:"12.5"`
+	MemPct              float64  `json:"memPct" example:"45.2"`
+	UptimeSecs          uint64   `json:"uptimeSecs" example:"86400"`
+	NetUp               uint64   `json:"netUp" example:"2097152"`
+	NetDown             uint64   `json:"netDown" example:"1048576"`
+	LastError           string   `json:"lastError" example:""`
+	XrayState           string   `json:"xrayState" example:"running"`
+	XrayError           string   `json:"xrayError" example:""`
+	ConfigDirty         bool     `json:"configDirty" example:"false"`
+	ConfigDirtyAt       int64    `json:"configDirtyAt" example:"0"`
+	InboundCount        int      `json:"inboundCount" example:"3"`
+	ClientCount         int      `json:"clientCount" example:"25"`
+	OnlineCount         int      `json:"onlineCount" example:"5"`
+	ActiveCount         int      `json:"activeCount" example:"20"`
+	DisabledCount       int      `json:"disabledCount" example:"2"`
+	DepletedCount       int      `json:"depletedCount" example:"1"`
+	ParentGuid          string   `json:"parentGuid,omitempty" example:""`
+	Transitive          bool     `json:"transitive,omitempty" example:"false"`
+	CreatedAt           int64    `json:"createdAt" example:"1700000000"`
+	UpdatedAt           int64    `json:"updatedAt" example:"1700003600"`
+}
+
+func toNodeView(n *model.Node) *NodeView {
+	if n == nil {
+		return nil
+	}
+	return &NodeView{
+		Id:                  n.Id,
+		Name:                n.Name,
+		Remark:              n.Remark,
+		Scheme:              n.Scheme,
+		Address:             n.Address,
+		Port:                n.Port,
+		BasePath:            n.BasePath,
+		HasApiToken:         n.ApiToken != "",
+		Enable:              n.Enable,
+		AllowPrivateAddress: n.AllowPrivateAddress,
+		TlsVerifyMode:       n.TlsVerifyMode,
+		PinnedCertSha256:    n.PinnedCertSha256,
+		InboundSyncMode:     n.InboundSyncMode,
+		InboundTags:         n.InboundTags,
+		OutboundTag:         n.OutboundTag,
+		Guid:                n.Guid,
+		Status:              n.Status,
+		LastHeartbeat:       n.LastHeartbeat,
+		LatencyMs:           n.LatencyMs,
+		XrayVersion:         n.XrayVersion,
+		PanelVersion:        n.PanelVersion,
+		CpuPct:              n.CpuPct,
+		MemPct:              n.MemPct,
+		UptimeSecs:          n.UptimeSecs,
+		NetUp:               n.NetUp,
+		NetDown:             n.NetDown,
+		LastError:           n.LastError,
+		XrayState:           n.XrayState,
+		XrayError:           n.XrayError,
+		ConfigDirty:         n.ConfigDirty,
+		ConfigDirtyAt:       n.ConfigDirtyAt,
+		InboundCount:        n.InboundCount,
+		ClientCount:         n.ClientCount,
+		OnlineCount:         n.OnlineCount,
+		ActiveCount:         n.ActiveCount,
+		DisabledCount:       n.DisabledCount,
+		DepletedCount:       n.DepletedCount,
+		ParentGuid:          n.ParentGuid,
+		Transitive:          n.Transitive,
+		CreatedAt:           n.CreatedAt,
+		UpdatedAt:           n.UpdatedAt,
+	}
+}
+
+func toNodeViews(nodes []*model.Node) []*NodeView {
+	views := make([]*NodeView, 0, len(nodes))
+	for _, node := range nodes {
+		views = append(views, toNodeView(node))
+	}
+	return views
+}
+
+// NodeMutationRequest is the node write/probe contract. ApiToken is accepted
+// only as input. On update, nil means keep the stored token; replacement and
+// clearing are explicit and mutually exclusive.
+type NodeMutationRequest struct {
+	Id                  int      `json:"id" form:"id"`
+	Name                string   `json:"name" form:"name" validate:"required"`
+	Remark              string   `json:"remark" form:"remark"`
+	Scheme              string   `json:"scheme" form:"scheme" validate:"omitempty,oneof=http https"`
+	Address             string   `json:"address" form:"address" validate:"required"`
+	Port                int      `json:"port" form:"port" validate:"gte=1,lte=65535"`
+	BasePath            string   `json:"basePath" form:"basePath"`
+	ApiToken            *string  `json:"apiToken,omitempty" form:"apiToken"`
+	ClearApiToken       bool     `json:"clearApiToken,omitempty" form:"clearApiToken"`
+	Enable              bool     `json:"enable" form:"enable"`
+	AllowPrivateAddress bool     `json:"allowPrivateAddress" form:"allowPrivateAddress"`
+	TlsVerifyMode       string   `json:"tlsVerifyMode" form:"tlsVerifyMode" validate:"omitempty,oneof=verify skip pin mtls"`
+	PinnedCertSha256    string   `json:"pinnedCertSha256" form:"pinnedCertSha256"`
+	InboundSyncMode     string   `json:"inboundSyncMode" form:"inboundSyncMode" validate:"omitempty,oneof=all selected"`
+	InboundTags         []string `json:"inboundTags" form:"inboundTags"`
+	OutboundTag         string   `json:"outboundTag" form:"outboundTag"`
+}
+
+func (r *NodeMutationRequest) validateCredentials(create bool) error {
+	if r == nil {
+		return common.NewError("node request is required")
+	}
+	if r.ApiToken != nil && r.ClearApiToken {
+		return common.NewError("apiToken and clearApiToken are mutually exclusive")
+	}
+	if r.ApiToken != nil {
+		*r.ApiToken = strings.TrimSpace(*r.ApiToken)
+		if *r.ApiToken == "" {
+			if create {
+				return common.NewError("apiToken is required unless mtls is enabled")
+			}
+			r.ApiToken = nil
+		}
+	}
+	if create {
+		if r.ClearApiToken {
+			return common.NewError("credentials cannot be cleared while creating a node")
+		}
+		if r.ApiToken == nil && r.TlsVerifyMode != "mtls" {
+			return common.NewError("apiToken is required unless mtls is enabled")
+		}
+	}
+	if r.ClearApiToken && r.Enable && r.TlsVerifyMode != "mtls" {
+		return common.NewError("disable the node or enable mtls before clearing its apiToken")
+	}
+	return nil
+}
+
+func (r *NodeMutationRequest) toNode() *model.Node {
+	n := &model.Node{
+		Id:                  r.Id,
+		Name:                r.Name,
+		Remark:              r.Remark,
+		Scheme:              r.Scheme,
+		Address:             r.Address,
+		Port:                r.Port,
+		BasePath:            r.BasePath,
+		Enable:              r.Enable,
+		AllowPrivateAddress: r.AllowPrivateAddress,
+		TlsVerifyMode:       r.TlsVerifyMode,
+		PinnedCertSha256:    r.PinnedCertSha256,
+		InboundSyncMode:     r.InboundSyncMode,
+		InboundTags:         r.InboundTags,
+		OutboundTag:         r.OutboundTag,
+	}
+	if r.ApiToken != nil {
+		n.ApiToken = *r.ApiToken
+	}
+	return n
+}

+ 201 - 0
internal/web/service/node_credentials_writeonly_test.go

@@ -0,0 +1,201 @@
+package service
+
+import (
+	"encoding/json"
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func TestNodeCredentialsNeverMarshal(t *testing.T) {
+	raw, err := json.Marshal(&model.Node{
+		Id:       7,
+		Name:     "node",
+		ApiToken: "plain-secret-token",
+	})
+	if err != nil {
+		t.Fatalf("marshal node: %v", err)
+	}
+	out := string(raw)
+	if strings.Contains(out, "plain-secret-token") || strings.Contains(out, "apiToken") {
+		t.Fatalf("model.Node JSON leaked api token field: %s", out)
+	}
+}
+
+func TestNodeViewExposesOnlyCredentialPresence(t *testing.T) {
+	setupConflictDB(t)
+
+	svc := &NodeService{}
+	reqToken := "write-only-secret"
+	view, err := svc.CreateFromRequest(&NodeMutationRequest{
+		Name:     "node-view",
+		Scheme:   "https",
+		Address:  "127.0.0.1",
+		Port:     2096,
+		ApiToken: &reqToken,
+		Enable:   true,
+	})
+	if err != nil {
+		t.Fatalf("create from request: %v", err)
+	}
+	if !view.HasApiToken {
+		t.Fatal("create view should report credential presence")
+	}
+
+	got, err := svc.GetViewById(view.Id)
+	if err != nil {
+		t.Fatalf("get view: %v", err)
+	}
+	raw, err := json.Marshal(got)
+	if err != nil {
+		t.Fatalf("marshal view: %v", err)
+	}
+	out := string(raw)
+	if !strings.Contains(out, `"hasApiToken":true`) {
+		t.Fatalf("view does not report credential presence: %s", out)
+	}
+	if strings.Contains(out, reqToken) || strings.Contains(out, "apiToken") {
+		t.Fatalf("NodeView leaked plaintext or apiToken key: %s", out)
+	}
+}
+
+func TestNodeCredentialMutationSemantics(t *testing.T) {
+	setupConflictDB(t)
+	svc := &NodeService{}
+
+	initial := "initial-token"
+	view, err := svc.CreateFromRequest(&NodeMutationRequest{
+		Name:     "mut",
+		Scheme:   "https",
+		Address:  "127.0.0.1",
+		Port:     2096,
+		ApiToken: &initial,
+		Enable:   true,
+	})
+	if err != nil {
+		t.Fatalf("create: %v", err)
+	}
+	before := rawStoredNodeToken(t, view.Id)
+	if before != initial {
+		t.Fatalf("stored token = %q, want %q", before, initial)
+	}
+
+	if err := svc.UpdateFromRequest(view.Id, &NodeMutationRequest{
+		Name:    "mut-renamed",
+		Scheme:  "https",
+		Address: "127.0.0.1",
+		Port:    2096,
+		Enable:  true,
+	}); err != nil {
+		t.Fatalf("keep-token update: %v", err)
+	}
+	if after := rawStoredNodeToken(t, view.Id); after != before {
+		t.Fatalf("omitted token should keep existing token: %q -> %q", before, after)
+	}
+
+	blank := " "
+	if err := svc.UpdateFromRequest(view.Id, &NodeMutationRequest{
+		Name:     "mut-blank",
+		Scheme:   "https",
+		Address:  "127.0.0.1",
+		Port:     2096,
+		ApiToken: &blank,
+		Enable:   true,
+	}); err != nil {
+		t.Fatalf("blank apiToken should keep existing token on update: %v", err)
+	}
+	if afterBlank := rawStoredNodeToken(t, view.Id); afterBlank != before {
+		t.Fatalf("blank token should keep existing token: %q -> %q", before, afterBlank)
+	}
+
+	next := "next-token"
+	if err := svc.UpdateFromRequest(view.Id, &NodeMutationRequest{
+		Name:     "mut",
+		Scheme:   "https",
+		Address:  "127.0.0.1",
+		Port:     2096,
+		ApiToken: &next,
+		Enable:   true,
+	}); err != nil {
+		t.Fatalf("replace token: %v", err)
+	}
+	if replaced := rawStoredNodeToken(t, view.Id); replaced != next {
+		t.Fatalf("replace token stored %q, want %q", replaced, next)
+	}
+
+	if err := svc.UpdateFromRequest(view.Id, &NodeMutationRequest{
+		Name:          "mut",
+		Scheme:        "https",
+		Address:       "127.0.0.1",
+		Port:          2096,
+		ClearApiToken: true,
+		Enable:        true,
+	}); err == nil {
+		t.Fatal("enabled non-mtls node must not clear apiToken")
+	}
+	if err := svc.UpdateFromRequest(view.Id, &NodeMutationRequest{
+		Name:          "mut",
+		Scheme:        "https",
+		Address:       "127.0.0.1",
+		Port:          2096,
+		ClearApiToken: true,
+		Enable:        false,
+	}); err != nil {
+		t.Fatalf("clear disabled token: %v", err)
+	}
+	if cleared := rawStoredNodeToken(t, view.Id); cleared != "" {
+		t.Fatalf("clear token left stored value %q", cleared)
+	}
+
+	if _, err := svc.CreateFromRequest(&NodeMutationRequest{
+		Name:          "mtls-only",
+		Scheme:        "https",
+		Address:       "127.0.0.1",
+		Port:          2097,
+		Enable:        true,
+		TlsVerifyMode: "mtls",
+	}); err != nil {
+		t.Fatalf("mtls create without token: %v", err)
+	}
+}
+
+func TestNodeUpdateRequiresTokenWhenNoStoredTokenAndMtlsDisabled(t *testing.T) {
+	setupConflictDB(t)
+	svc := &NodeService{}
+
+	view, err := svc.CreateFromRequest(&NodeMutationRequest{
+		Name:          "mtls-empty",
+		Scheme:        "https",
+		Address:       "127.0.0.1",
+		Port:          2098,
+		Enable:        true,
+		TlsVerifyMode: "mtls",
+	})
+	if err != nil {
+		t.Fatalf("create mtls node: %v", err)
+	}
+	blank := ""
+	if err := svc.UpdateFromRequest(view.Id, &NodeMutationRequest{
+		Name:        "mtls-empty",
+		Scheme:      "https",
+		Address:     "127.0.0.1",
+		Port:        2098,
+		ApiToken:    &blank,
+		Enable:      true,
+		BasePath:    "/",
+		OutboundTag: "",
+	}); err == nil {
+		t.Fatal("enabled non-mtls node without stored token must be rejected")
+	}
+}
+
+func rawStoredNodeToken(t *testing.T, id int) string {
+	t.Helper()
+	var n model.Node
+	if err := database.GetDB().Select("api_token").Where("id = ?", id).First(&n).Error; err != nil {
+		t.Fatalf("load raw node token: %v", err)
+	}
+	return n.ApiToken
+}

+ 8 - 0
internal/web/service/node_tree.go

@@ -157,6 +157,14 @@ func (s *NodeService) GetNodeTree() ([]*model.Node, error) {
 	return all, nil
 	return all, nil
 }
 }
 
 
+func (s *NodeService) GetNodeTreeView() ([]*NodeView, error) {
+	nodes, err := s.GetNodeTree()
+	if err != nil {
+		return nil, err
+	}
+	return toNodeViews(nodes), nil
+}
+
 // recountByGuid recomputes InboundCount/OnlineCount/DepletedCount for every node
 // recountByGuid recomputes InboundCount/OnlineCount/DepletedCount for every node
 // in the tree, keyed by the GUID that physically hosts each inbound, so a direct
 // in the tree, keyed by the GUID that physically hosts each inbound, so a direct
 // node shows only its own inbounds and each transitive node shows its own
 // node shows only its own inbounds and each transitive node shows its own

+ 32 - 10
internal/web/service/outbound_subscription.go

@@ -2,6 +2,7 @@ package service
 
 
 import (
 import (
 	"context"
 	"context"
+	"crypto/tls"
 	"encoding/json"
 	"encoding/json"
 	"errors"
 	"errors"
 	"fmt"
 	"fmt"
@@ -24,14 +25,20 @@ import (
 // filterOutboundsRejectedByCore drops outbounds the vendored xray-core config
 // filterOutboundsRejectedByCore drops outbounds the vendored xray-core config
 // loader refuses to build — since v26.7.11 that includes unencrypted
 // loader refuses to build — since v26.7.11 that includes unencrypted
 // vless/trojan outbounds to public addresses — because one such outbound in
 // vless/trojan outbounds to public addresses — because one such outbound in
-// the merged config would keep the whole core from starting.
+// the merged config would keep the whole core from starting. When the running
+// core predates that rejection, unencrypted outbounds are kept, mirroring
+// CheckXrayConfig's version gate.
 func filterOutboundsRejectedByCore(label string, outbounds []any) ([]any, []string) {
 func filterOutboundsRejectedByCore(label string, outbounds []any) ([]any, []string) {
+	coreVersion := "Unknown"
+	if p != nil {
+		coreVersion = p.GetXrayVersion()
+	}
 	kept := make([]any, 0, len(outbounds))
 	kept := make([]any, 0, len(outbounds))
 	var dropped []string
 	var dropped []string
 	for _, ob := range outbounds {
 	for _, ob := range outbounds {
 		raw, err := json.Marshal(ob)
 		raw, err := json.Marshal(ob)
 		if err == nil {
 		if err == nil {
-			if buildErr := xray.ValidateOutboundConfig(raw); buildErr != nil {
+			if buildErr := xray.ValidateOutboundConfig(raw); buildErr != nil && !shouldSkipLegacyUnencryptedOutboundRejection(coreVersion, buildErr) {
 				tag := ""
 				tag := ""
 				if m, ok := ob.(map[string]any); ok {
 				if m, ok := ob.(map[string]any); ok {
 					tag, _ = m["tag"].(string)
 					tag, _ = m["tag"].(string)
@@ -155,7 +162,7 @@ func (s *OutboundSubscriptionService) nextDefaultSubPrefix(excludeId int) string
 	return fmt.Sprintf("sub%d-", defaultPrefixNumber(subs, excludeId))
 	return fmt.Sprintf("sub%d-", defaultPrefixNumber(subs, excludeId))
 }
 }
 
 
-func (s *OutboundSubscriptionService) Create(remark, rawURL, tagPrefix string, enabled bool, updateInterval int, allowPrivate, prepend bool) (*model.OutboundSubscription, error) {
+func (s *OutboundSubscriptionService) Create(remark, rawURL, tagPrefix string, enabled bool, updateInterval int, allowPrivate, prepend, allowInsecure bool) (*model.OutboundSubscription, error) {
 	cleanURL, err := SanitizePublicHTTPURL(rawURL, allowPrivate)
 	cleanURL, err := SanitizePublicHTTPURL(rawURL, allowPrivate)
 	if err != nil {
 	if err != nil {
 		return nil, common.NewError("invalid subscription URL:", err)
 		return nil, common.NewError("invalid subscription URL:", err)
@@ -178,6 +185,7 @@ func (s *OutboundSubscriptionService) Create(remark, rawURL, tagPrefix string, e
 		Url:            cleanURL,
 		Url:            cleanURL,
 		Enabled:        enabled,
 		Enabled:        enabled,
 		AllowPrivate:   allowPrivate,
 		AllowPrivate:   allowPrivate,
+		AllowInsecure:  allowInsecure,
 		Prepend:        prepend,
 		Prepend:        prepend,
 		Priority:       int(count),
 		Priority:       int(count),
 		TagPrefix:      prefix,
 		TagPrefix:      prefix,
@@ -190,7 +198,7 @@ func (s *OutboundSubscriptionService) Create(remark, rawURL, tagPrefix string, e
 }
 }
 
 
 // Update updates editable fields.
 // Update updates editable fields.
-func (s *OutboundSubscriptionService) Update(id int, remark, rawURL, tagPrefix string, enabled bool, updateInterval int, allowPrivate, prepend bool) error {
+func (s *OutboundSubscriptionService) Update(id int, remark, rawURL, tagPrefix string, enabled bool, updateInterval int, allowPrivate, prepend, allowInsecure bool) error {
 	sub, err := s.Get(id)
 	sub, err := s.Get(id)
 	if err != nil {
 	if err != nil {
 		return err
 		return err
@@ -213,6 +221,7 @@ func (s *OutboundSubscriptionService) Update(id int, remark, rawURL, tagPrefix s
 	sub.Url = cleanURL
 	sub.Url = cleanURL
 	sub.Enabled = enabled
 	sub.Enabled = enabled
 	sub.AllowPrivate = allowPrivate
 	sub.AllowPrivate = allowPrivate
+	sub.AllowInsecure = allowInsecure
 	sub.Prepend = prepend
 	sub.Prepend = prepend
 	sub.TagPrefix = prefix
 	sub.TagPrefix = prefix
 	sub.UpdateInterval = updateInterval
 	sub.UpdateInterval = updateInterval
@@ -284,14 +293,27 @@ func (s *OutboundSubscriptionService) RefreshAllEnabled() (int, error) {
 // goes through the SSRF-guarded dialer, which resolves, checks and dials the same
 // goes through the SSRF-guarded dialer, which resolves, checks and dials the same
 // IP atomically — closing the DNS-rebinding gap left by validating the hostname
 // IP atomically — closing the DNS-rebinding gap left by validating the hostname
 // separately from the dial.
 // separately from the dial.
-func (s *OutboundSubscriptionService) subscriptionFetchClient(timeout time.Duration) *http.Client {
+func (s *OutboundSubscriptionService) subscriptionFetchClient(timeout time.Duration, allowInsecure bool) *http.Client {
+	var client *http.Client
 	if s.settingService.PanelEgressProxyURL() != "" {
 	if s.settingService.PanelEgressProxyURL() != "" {
-		return s.settingService.NewProxiedHTTPClient(timeout)
+		client = s.settingService.NewProxiedHTTPClient(timeout)
+	} else {
+		client = &http.Client{
+			Timeout:   timeout,
+			Transport: &http.Transport{DialContext: netsafe.SSRFGuardedDialContext},
+		}
 	}
 	}
-	return &http.Client{
-		Timeout:   timeout,
-		Transport: &http.Transport{DialContext: netsafe.SSRFGuardedDialContext},
+	if allowInsecure {
+		if tr, ok := client.Transport.(*http.Transport); ok && tr != nil {
+			cloned := tr.Clone()
+			if cloned.TLSClientConfig == nil {
+				cloned.TLSClientConfig = &tls.Config{}
+			}
+			cloned.TLSClientConfig.InsecureSkipVerify = true
+			client.Transport = cloned
+		}
 	}
 	}
+	return client
 }
 }
 
 
 // fetchAndStore does the actual network + parse + stability + persist work.
 // fetchAndStore does the actual network + parse + stability + persist work.
@@ -309,7 +331,7 @@ func (s *OutboundSubscriptionService) fetchAndStore(sub *model.OutboundSubscript
 	}
 	}
 	sub.Url = cleanURL // persist the cleaned version
 	sub.Url = cleanURL // persist the cleaned version
 
 
-	client := s.subscriptionFetchClient(30 * time.Second)
+	client := s.subscriptionFetchClient(30*time.Second, sub.AllowInsecure)
 	// Re-validate every redirect hop: the initial host is checked above, but a
 	// Re-validate every redirect hop: the initial host is checked above, but a
 	// redirect could still point at a private/internal address (SSRF). Cap the
 	// redirect could still point at a private/internal address (SSRF). Cap the
 	// redirect chain as well.
 	// redirect chain as well.

+ 1 - 1
internal/web/service/outbound_subscription_ssrf_test.go

@@ -12,7 +12,7 @@ import (
 
 
 func TestSubscriptionFetchClientBlocksPrivateDial(t *testing.T) {
 func TestSubscriptionFetchClientBlocksPrivateDial(t *testing.T) {
 	setupSettingTestDB(t)
 	setupSettingTestDB(t)
-	client := (&OutboundSubscriptionService{}).subscriptionFetchClient(5 * time.Second)
+	client := (&OutboundSubscriptionService{}).subscriptionFetchClient(5*time.Second, false)
 
 
 	ctx := netsafe.ContextWithAllowPrivate(context.Background(), false)
 	ctx := netsafe.ContextWithAllowPrivate(context.Background(), false)
 	req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://127.0.0.1:1/", nil)
 	req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://127.0.0.1:1/", nil)

+ 69 - 6
internal/web/service/reality_scan.go

@@ -170,7 +170,7 @@ func enumerateCIDR(cidr string, max int) ([]string, error) {
 	return ips, nil
 	return ips, nil
 }
 }
 
 
-func (s *ServerService) probeRealityAddr(dialHost string, port int, sni string, timeout time.Duration) *RealityScanResult {
+func (s *ServerService) probeRealityAddr(dialHost string, port int, sni string, timeout time.Duration, xver int) *RealityScanResult {
 	addr := net.JoinHostPort(dialHost, strconv.Itoa(port))
 	addr := net.JoinHostPort(dialHost, strconv.Itoa(port))
 	res := &RealityScanResult{Port: port}
 	res := &RealityScanResult{Port: port}
 	if net.ParseIP(dialHost) != nil {
 	if net.ParseIP(dialHost) != nil {
@@ -196,6 +196,17 @@ func (s *ServerService) probeRealityAddr(dialHost string, port int, sni string,
 	defer conn.Close()
 	defer conn.Close()
 	_ = conn.SetDeadline(time.Now().Add(timeout))
 	_ = conn.SetDeadline(time.Now().Add(timeout))
 
 
+	// A REALITY inbound with xver>=1 fronts a target that speaks the PROXY
+	// protocol (e.g. an Nginx listener with `proxy_protocol`), so the probe
+	// must lead with a PROXY header or the target resets the connection and
+	// the scan reports a spurious handshake failure (#6082).
+	if xver >= 1 {
+		if err := writeProxyProtocolHeader(conn, xver); err != nil {
+			res.Reason = "proxy protocol write failed: " + err.Error()
+			return res
+		}
+	}
+
 	cfg := &tls.Config{
 	cfg := &tls.Config{
 		ServerName:         sni,
 		ServerName:         sni,
 		InsecureSkipVerify: true,
 		InsecureSkipVerify: true,
@@ -272,16 +283,16 @@ func (s *ServerService) probeRealityAddr(dialHost string, port int, sni string,
 	return res
 	return res
 }
 }
 
 
-func (s *ServerService) probeRealityTarget(host string, port int) *RealityScanResult {
-	return s.probeRealityAddr(host, port, host, realityScanTimeout)
+func (s *ServerService) probeRealityTarget(host string, port int, xver int) *RealityScanResult {
+	return s.probeRealityAddr(host, port, host, realityScanTimeout, xver)
 }
 }
 
 
-func (s *ServerService) ScanRealityTarget(target string) (*RealityScanResult, error) {
+func (s *ServerService) ScanRealityTarget(target string, xver int) (*RealityScanResult, error) {
 	host, port, err := splitRealityTarget(target)
 	host, port, err := splitRealityTarget(target)
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
-	return s.probeRealityTarget(host, port), nil
+	return s.probeRealityTarget(host, port, xver), nil
 }
 }
 
 
 func (s *ServerService) ScanRealityTargets(targetsCSV string) ([]*RealityScanResult, error) {
 func (s *ServerService) ScanRealityTargets(targetsCSV string) ([]*RealityScanResult, error) {
@@ -336,7 +347,7 @@ func (s *ServerService) ScanRealityTargets(targetsCSV string) ([]*RealityScanRes
 		go func(idx int, tk realityProbeTask) {
 		go func(idx int, tk realityProbeTask) {
 			defer wg.Done()
 			defer wg.Done()
 			defer func() { <-sem }()
 			defer func() { <-sem }()
-			r := s.probeRealityAddr(tk.dialHost, tk.port, tk.sni, tk.timeout)
+			r := s.probeRealityAddr(tk.dialHost, tk.port, tk.sni, tk.timeout, 0)
 			if tk.bulk && r.TLSVersion == "" {
 			if tk.bulk && r.TLSVersion == "" {
 				return
 				return
 			}
 			}
@@ -389,3 +400,55 @@ func sortRealityResults(results []*RealityScanResult) {
 		return a.LatencyMs - b.LatencyMs
 		return a.LatencyMs - b.LatencyMs
 	})
 	})
 }
 }
+
+// writeProxyProtocolHeader emits a PROXY protocol header describing the local
+// connection so a target that requires it (Nginx `proxy_protocol`, matching a
+// REALITY inbound's xver) accepts the probe instead of resetting it. xver 1
+// sends the human-readable v1 header; xver 2 sends the binary v2 header. The
+// addresses come from the already-dialed connection, so they are always a
+// consistent, real (src, dst) pair.
+func writeProxyProtocolHeader(conn net.Conn, xver int) error {
+	local, lok := conn.LocalAddr().(*net.TCPAddr)
+	remote, rok := conn.RemoteAddr().(*net.TCPAddr)
+	if !lok || !rok {
+		return fmt.Errorf("connection has no TCP addresses")
+	}
+	if xver >= 2 {
+		return writeProxyProtocolV2(conn, local, remote)
+	}
+	return writeProxyProtocolV1(conn, local, remote)
+}
+
+func writeProxyProtocolV1(conn net.Conn, local, remote *net.TCPAddr) error {
+	fam := "TCP4"
+	if local.IP.To4() == nil || remote.IP.To4() == nil {
+		fam = "TCP6"
+	}
+	header := fmt.Sprintf("PROXY %s %s %s %d %d\r\n", fam, local.IP.String(), remote.IP.String(), local.Port, remote.Port)
+	_, err := conn.Write([]byte(header))
+	return err
+}
+
+func writeProxyProtocolV2(conn net.Conn, local, remote *net.TCPAddr) error {
+	buf := []byte{0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A}
+	buf = append(buf, 0x21)
+
+	src4, dst4 := local.IP.To4(), remote.IP.To4()
+	if src4 != nil && dst4 != nil {
+		buf = append(buf, 0x11)
+		buf = append(buf, 0x00, 12)
+		buf = append(buf, src4...)
+		buf = append(buf, dst4...)
+		buf = append(buf, byte(local.Port>>8), byte(local.Port))
+		buf = append(buf, byte(remote.Port>>8), byte(remote.Port))
+	} else {
+		buf = append(buf, 0x21)
+		buf = append(buf, 0x00, 36)
+		buf = append(buf, local.IP.To16()...)
+		buf = append(buf, remote.IP.To16()...)
+		buf = append(buf, byte(local.Port>>8), byte(local.Port))
+		buf = append(buf, byte(remote.Port>>8), byte(remote.Port))
+	}
+	_, err := conn.Write(buf)
+	return err
+}

+ 57 - 2
internal/web/service/reality_scan_test.go

@@ -2,6 +2,7 @@ package service
 
 
 import (
 import (
 	"crypto/tls"
 	"crypto/tls"
+	"net"
 	"testing"
 	"testing"
 )
 )
 
 
@@ -77,13 +78,13 @@ func TestSplitRealityTarget(t *testing.T) {
 }
 }
 
 
 func TestScanRealityTargetInputValidation(t *testing.T) {
 func TestScanRealityTargetInputValidation(t *testing.T) {
-	if _, err := (&ServerService{}).ScanRealityTarget(""); err == nil {
+	if _, err := (&ServerService{}).ScanRealityTarget("", 0); err == nil {
 		t.Error("ScanRealityTarget(empty) expected error, got nil")
 		t.Error("ScanRealityTarget(empty) expected error, got nil")
 	}
 	}
 }
 }
 
 
 func TestScanRealityTargetBlocksPrivate(t *testing.T) {
 func TestScanRealityTargetBlocksPrivate(t *testing.T) {
-	res, err := (&ServerService{}).ScanRealityTarget("127.0.0.1:443")
+	res, err := (&ServerService{}).ScanRealityTarget("127.0.0.1:443", 0)
 	if err != nil {
 	if err != nil {
 		t.Fatalf("ScanRealityTarget(loopback) unexpected error: %v", err)
 		t.Fatalf("ScanRealityTarget(loopback) unexpected error: %v", err)
 	}
 	}
@@ -109,3 +110,57 @@ func TestScanRealityTargetsHandlesPrivateAndBadInput(t *testing.T) {
 		}
 		}
 	}
 	}
 }
 }
+
+func TestWriteProxyProtocolV1(t *testing.T) {
+	server, client := net.Pipe()
+	defer client.Close()
+
+	local := &net.TCPAddr{IP: net.ParseIP("192.0.2.10"), Port: 51234}
+	remote := &net.TCPAddr{IP: net.ParseIP("203.0.113.5"), Port: 443}
+
+	got := make(chan string, 1)
+	go func() {
+		buf := make([]byte, 128)
+		n, _ := server.Read(buf)
+		got <- string(buf[:n])
+	}()
+
+	if err := writeProxyProtocolV1(client, local, remote); err != nil {
+		t.Fatalf("writeProxyProtocolV1: %v", err)
+	}
+	line := <-got
+	want := "PROXY TCP4 192.0.2.10 203.0.113.5 51234 443\r\n"
+	if line != want {
+		t.Fatalf("v1 header = %q, want %q", line, want)
+	}
+}
+
+func TestWriteProxyProtocolV2Signature(t *testing.T) {
+	server, client := net.Pipe()
+	defer client.Close()
+
+	local := &net.TCPAddr{IP: net.ParseIP("192.0.2.10"), Port: 51234}
+	remote := &net.TCPAddr{IP: net.ParseIP("203.0.113.5"), Port: 443}
+
+	got := make(chan []byte, 1)
+	go func() {
+		buf := make([]byte, 128)
+		n, _ := server.Read(buf)
+		got <- append([]byte(nil), buf[:n]...)
+	}()
+
+	if err := writeProxyProtocolV2(client, local, remote); err != nil {
+		t.Fatalf("writeProxyProtocolV2: %v", err)
+	}
+	hdr := <-got
+	sig := []byte{0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A}
+	if len(hdr) < 16 || string(hdr[:12]) != string(sig) {
+		t.Fatalf("v2 header missing the protocol signature: %v", hdr)
+	}
+	if hdr[12] != 0x21 {
+		t.Fatalf("v2 version/command byte = 0x%02x, want 0x21", hdr[12])
+	}
+	if hdr[13] != 0x11 {
+		t.Fatalf("v2 family/protocol byte = 0x%02x, want 0x11 (TCP over IPv4)", hdr[13])
+	}
+}

+ 18 - 5
internal/web/service/xray_metrics.go

@@ -5,11 +5,12 @@ import (
 	"encoding/json"
 	"encoding/json"
 	"fmt"
 	"fmt"
 	"net/http"
 	"net/http"
-	"regexp"
 	"sort"
 	"sort"
 	"strings"
 	"strings"
 	"sync"
 	"sync"
 	"time"
 	"time"
+	"unicode"
+	"unicode/utf8"
 
 
 	"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
 	"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
@@ -61,7 +62,19 @@ type outboundHealth struct {
 	notified   bool
 	notified   bool
 }
 }
 
 
-var validObsTag = regexp.MustCompile(`^[a-zA-Z0-9._\-]+$`)
+const maxObsTagLength = 128
+
+func validObsTag(tag string) bool {
+	if tag == "" || len(tag) > maxObsTagLength || !utf8.ValidString(tag) {
+		return false
+	}
+	for _, r := range tag {
+		if unicode.IsControl(r) {
+			return false
+		}
+	}
+	return true
+}
 
 
 func obsHistoryKey(tag string) string {
 func obsHistoryKey(tag string) string {
 	return "xrObs." + tag + ".delay"
 	return "xrObs." + tag + ".delay"
@@ -102,7 +115,7 @@ func (s *XrayMetricsService) ObservatorySnapshot() []ObsTagSnapshot {
 }
 }
 
 
 func (s *XrayMetricsService) HasObservatoryTag(tag string) bool {
 func (s *XrayMetricsService) HasObservatoryTag(tag string) bool {
-	if !validObsTag.MatchString(tag) {
+	if !validObsTag(tag) {
 		return false
 		return false
 	}
 	}
 	s.mu.RLock()
 	s.mu.RLock()
@@ -112,7 +125,7 @@ func (s *XrayMetricsService) HasObservatoryTag(tag string) bool {
 }
 }
 
 
 func (s *XrayMetricsService) AggregateObservatory(tag string, bucketSeconds, maxPoints int) []map[string]any {
 func (s *XrayMetricsService) AggregateObservatory(tag string, bucketSeconds, maxPoints int) []map[string]any {
-	if !validObsTag.MatchString(tag) {
+	if !validObsTag(tag) {
 		return []map[string]any{}
 		return []map[string]any{}
 	}
 	}
 	return xrayMetrics.aggregate(obsHistoryKey(tag), bucketSeconds, maxPoints)
 	return xrayMetrics.aggregate(obsHistoryKey(tag), bucketSeconds, maxPoints)
@@ -212,7 +225,7 @@ func (s *XrayMetricsService) applyObservatory(t time.Time, entries map[string]ra
 		if tag == "" {
 		if tag == "" {
 			tag = key
 			tag = key
 		}
 		}
-		if !validObsTag.MatchString(tag) {
+		if !validObsTag(tag) {
 			continue
 			continue
 		}
 		}
 		snap := ObsTagSnapshot{
 		snap := ObsTagSnapshot{

+ 48 - 0
internal/web/service/xray_metrics_test.go

@@ -2,6 +2,7 @@ package service
 
 
 import (
 import (
 	"path/filepath"
 	"path/filepath"
+	"strings"
 	"testing"
 	"testing"
 	"time"
 	"time"
 
 
@@ -120,3 +121,50 @@ func TestApplyObservatoryDebounce(t *testing.T) {
 		})
 		})
 	}
 	}
 }
 }
+
+func TestValidObsTag(t *testing.T) {
+	tests := []struct {
+		name string
+		tag  string
+		want bool
+	}{
+		{"plain ascii", "proxy-1", true},
+		{"dots and underscores", "warp_us.east", true},
+		{"flag emoji", "🇩🇪 Germany", true},
+		{"cyrillic", "Германия", true},
+		{"spaces allowed", "US proxy 2", true},
+		{"empty rejected", "", false},
+		{"control char rejected", "bad\x00tag", false},
+		{"newline rejected", "bad\ntag", false},
+		{"invalid utf8 rejected", string([]byte{0xff, 0xfe}), false},
+		{"overlong rejected", strings.Repeat("a", maxObsTagLength+1), false},
+	}
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			if got := validObsTag(tc.tag); got != tc.want {
+				t.Fatalf("validObsTag(%q) = %v, want %v", tc.tag, got, tc.want)
+			}
+		})
+	}
+}
+
+func TestApplyObservatoryKeepsUnicodeTags(t *testing.T) {
+	dbDir := t.TempDir()
+	if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
+	s := &XrayMetricsService{settingService: SettingService{}}
+	s.applyObservatory(time.Unix(1000, 0), map[string]rawObsEntry{
+		"🇩🇪 Berlin": {Alive: true, Delay: 42, LastTryTime: 1},
+	})
+
+	if !s.HasObservatoryTag("🇩🇪 Berlin") {
+		t.Fatal("emoji-tagged outbound must appear in the observatory")
+	}
+	snaps := s.ObservatorySnapshot()
+	if len(snaps) != 1 || snaps[0].Tag != "🇩🇪 Berlin" {
+		t.Fatalf("snapshot = %+v, want the emoji tag", snaps)
+	}
+}

+ 37 - 7
internal/web/service/xray_setting_dns_routing.go

@@ -4,6 +4,7 @@ import (
 	"encoding/json"
 	"encoding/json"
 	"net"
 	"net"
 	"reflect"
 	"reflect"
+	"slices"
 	"sort"
 	"sort"
 	"strconv"
 	"strconv"
 	"strings"
 	"strings"
@@ -11,7 +12,39 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
 )
 )
 
 
-// dnsAllowRuleShape identifies routing rules this file manages: a plain
+// dnsAllowRuleTag marks the routing rules this file manages. Both
+// xray-core and the Routing tab's rule editor round-trip ruleTag
+// untouched, so it is a stable provenance marker: only rules carrying it
+// (or legacy managed rules recognized by exact content, see
+// managedDnsAllowRule) are ever stripped and rebuilt.
+const dnsAllowRuleTag = "xui-dns-allow"
+
+// managedDnsAllowRule reports whether a routing rule was created by this
+// file. Current managed rules carry dnsAllowRuleTag. Rules written before
+// the tag existed are adopted only when their shape matches AND their
+// exact ip-set/port content equals a currently configured private DNS
+// endpoint group — a hand-written rule that merely resembles the managed
+// shape (e.g. a CIDR allow for a NAS on port 5000, #6056) never matches
+// and is left untouched.
+func managedDnsAllowRule(rule map[string]any, groups []dnsAllowPortGroup) bool {
+	if tag, _ := rule["ruleTag"].(string); tag == dnsAllowRuleTag {
+		return true
+	}
+	if !dnsAllowRuleShape(rule) {
+		return false
+	}
+	port, _ := rule["port"].(string)
+	ips := append([]string(nil), readRuleIPs(rule["ip"])...)
+	sort.Strings(ips)
+	for _, g := range groups {
+		if strconv.Itoa(g.port) == port && slices.Equal(ips, g.ips) {
+			return true
+		}
+	}
+	return false
+}
+
+// dnsAllowRuleShape identifies the legacy managed-rule shape: a plain
 // "type=field, ip=[...], port=..., outboundTag=direct" rule with no other
 // "type=field, ip=[...], port=..., outboundTag=direct" rule with no other
 // matchers. An "enabled" key is tolerated as long as it's true — the
 // matchers. An "enabled" key is tolerated as long as it's true — the
 // Routing tab's rule editor (RuleFormModal.tsx submit()) and its enabled
 // Routing tab's rule editor (RuleFormModal.tsx submit()) and its enabled
@@ -21,10 +54,6 @@ import (
 // toggled off (enabled=false) is treated as no longer ours: the admin
 // toggled off (enabled=false) is treated as no longer ours: the admin
 // explicitly turned it off, and re-enabling it on the next save would
 // explicitly turned it off, and re-enabling it on the next save would
 // silently override that choice.
 // silently override that choice.
-//
-// Rules shaped like this are kept in sync with the current dns.servers
-// config on every save; anything else (including rules an admin wrote by
-// hand that happen to also allow-list an IP) is left untouched.
 func dnsAllowRuleShape(rule map[string]any) bool {
 func dnsAllowRuleShape(rule map[string]any) bool {
 	if t, _ := rule["type"].(string); t != "field" {
 	if t, _ := rule["type"].(string); t != "field" {
 		return false
 		return false
@@ -270,7 +299,7 @@ func collectPrivateDnsAllowGroups(dnsRaw json.RawMessage) []dnsAllowPortGroup {
 // otherwise silently reintroduce the stall with nothing to notice or fix
 // otherwise silently reintroduce the stall with nothing to notice or fix
 // it). The rebuilt result is only written back if it actually differs
 // it). The rebuilt result is only written back if it actually differs
 // from the input, so well-formed configs aren't churned on every save.
 // from the input, so well-formed configs aren't churned on every save.
-// Manually-authored rules are never touched — see dnsAllowRuleShape.
+// Manually-authored rules are never touched — see managedDnsAllowRule.
 func EnsureDnsServerRouting(raw string) (string, error) {
 func EnsureDnsServerRouting(raw string) (string, error) {
 	var cfg map[string]json.RawMessage
 	var cfg map[string]json.RawMessage
 	if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
 	if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
@@ -339,7 +368,7 @@ func EnsureDnsServerRouting(raw string) (string, error) {
 func rebuildDnsAllowRules(rules []map[string]any, groups []dnsAllowPortGroup) []map[string]any {
 func rebuildDnsAllowRules(rules []map[string]any, groups []dnsAllowPortGroup) []map[string]any {
 	clean := make([]map[string]any, 0, len(rules))
 	clean := make([]map[string]any, 0, len(rules))
 	for _, rule := range rules {
 	for _, rule := range rules {
-		if !dnsAllowRuleShape(rule) {
+		if !managedDnsAllowRule(rule, groups) {
 			clean = append(clean, rule)
 			clean = append(clean, rule)
 		}
 		}
 	}
 	}
@@ -353,6 +382,7 @@ func rebuildDnsAllowRules(rules []map[string]any, groups []dnsAllowPortGroup) []
 	for _, g := range groups {
 	for _, g := range groups {
 		managed = append(managed, map[string]any{
 		managed = append(managed, map[string]any{
 			"type":        "field",
 			"type":        "field",
+			"ruleTag":     dnsAllowRuleTag,
 			"ip":          g.ips,
 			"ip":          g.ips,
 			"port":        strconv.Itoa(g.port),
 			"port":        strconv.Itoa(g.port),
 			"outboundTag": "direct",
 			"outboundTag": "direct",

+ 63 - 7
internal/web/service/xray_setting_dns_routing_test.go

@@ -216,6 +216,8 @@ func TestEnsureDnsServerRouting_IdempotentOnSecondSave(t *testing.T) {
 }
 }
 
 
 func TestEnsureDnsServerRouting_UpdatesOwnedRuleWhenServersChange(t *testing.T) {
 func TestEnsureDnsServerRouting_UpdatesOwnedRuleWhenServersChange(t *testing.T) {
+	// A legacy (untagged) managed rule matching the current dns.servers is
+	// adopted: rebuilt once with the ruleTag marker, then stable.
 	in := `{
 	in := `{
 		"dns": {"servers": ["172.20.0.53"]},
 		"dns": {"servers": ["172.20.0.53"]},
 		"routing": {
 		"routing": {
@@ -229,8 +231,19 @@ func TestEnsureDnsServerRouting_UpdatesOwnedRuleWhenServersChange(t *testing.T)
 	if err != nil {
 	if err != nil {
 		t.Fatalf("unexpected err: %v", err)
 		t.Fatalf("unexpected err: %v", err)
 	}
 	}
-	if out != in {
-		t.Fatalf("dns servers unchanged, expected no-op, got: %s", out)
+	rules := rulesFromRaw(t, out)
+	if len(rules) != 2 {
+		t.Fatalf("rules len = %d, want 2 (legacy rule adopted in place): %s", len(rules), out)
+	}
+	if tag, _ := rules[0]["ruleTag"].(string); tag != dnsAllowRuleTag {
+		t.Fatalf("adopted rule should carry %q, got %v", dnsAllowRuleTag, rules[0])
+	}
+	stable, err := EnsureDnsServerRouting(out)
+	if err != nil {
+		t.Fatalf("unexpected err: %v", err)
+	}
+	if stable != out {
+		t.Fatalf("expected no further change once tagged\nfirst:  %s\nsecond: %s", out, stable)
 	}
 	}
 
 
 	// Admin adds a second internal resolver on the same port.
 	// Admin adds a second internal resolver on the same port.
@@ -238,7 +251,7 @@ func TestEnsureDnsServerRouting_UpdatesOwnedRuleWhenServersChange(t *testing.T)
 		"dns": {"servers": ["172.20.0.53", "10.0.0.53"]},
 		"dns": {"servers": ["172.20.0.53", "10.0.0.53"]},
 		"routing": {
 		"routing": {
 			"rules": [
 			"rules": [
-				{"type":"field","ip":["172.20.0.53"],"port":"53","outboundTag":"direct"},
+				{"type":"field","ruleTag":"xui-dns-allow","ip":["172.20.0.53"],"port":"53","outboundTag":"direct"},
 				{"type":"field","outboundTag":"blocked","ip":["geoip:private"]}
 				{"type":"field","outboundTag":"blocked","ip":["geoip:private"]}
 			]
 			]
 		}
 		}
@@ -247,7 +260,7 @@ func TestEnsureDnsServerRouting_UpdatesOwnedRuleWhenServersChange(t *testing.T)
 	if err != nil {
 	if err != nil {
 		t.Fatalf("unexpected err: %v", err)
 		t.Fatalf("unexpected err: %v", err)
 	}
 	}
-	rules := rulesFromRaw(t, out2)
+	rules = rulesFromRaw(t, out2)
 	if len(rules) != 2 {
 	if len(rules) != 2 {
 		t.Fatalf("rules len = %d, want 2 (existing rule updated in place): %s", len(rules), out2)
 		t.Fatalf("rules len = %d, want 2 (existing rule updated in place): %s", len(rules), out2)
 	}
 	}
@@ -258,13 +271,13 @@ func TestEnsureDnsServerRouting_UpdatesOwnedRuleWhenServersChange(t *testing.T)
 }
 }
 
 
 func TestEnsureDnsServerRouting_RemovesOwnedRuleWhenNoLongerNeeded(t *testing.T) {
 func TestEnsureDnsServerRouting_RemovesOwnedRuleWhenNoLongerNeeded(t *testing.T) {
-	// Admin switches dns.servers to a public resolver — our previously
-	// inserted allow-rule is now dead weight and should be dropped.
+	// Admin switches dns.servers to a public resolver — the tagged managed
+	// rule is now dead weight and should be dropped.
 	in := `{
 	in := `{
 		"dns": {"servers": ["1.1.1.1"]},
 		"dns": {"servers": ["1.1.1.1"]},
 		"routing": {
 		"routing": {
 			"rules": [
 			"rules": [
-				{"type":"field","ip":["172.20.0.53"],"port":"53","outboundTag":"direct"},
+				{"type":"field","ruleTag":"xui-dns-allow","ip":["172.20.0.53"],"port":"53","outboundTag":"direct"},
 				{"type":"field","outboundTag":"blocked","ip":["geoip:private"]}
 				{"type":"field","outboundTag":"blocked","ip":["geoip:private"]}
 			]
 			]
 		}
 		}
@@ -282,6 +295,49 @@ func TestEnsureDnsServerRouting_RemovesOwnedRuleWhenNoLongerNeeded(t *testing.T)
 	}
 	}
 }
 }
 
 
+func TestEnsureDnsServerRouting_KeepsManualDirectRuleOnSave(t *testing.T) {
+	// The #6056 regression: a hand-written "LAN service over direct" rule
+	// (CIDR ip + custom port, no ruleTag) matches nothing the panel
+	// manages and must survive a save untouched, even when dns.servers has
+	// no private entries at all.
+	in := `{
+		"dns": {"servers": ["1.1.1.1"]},
+		"routing": {
+			"rules": [
+				{"type":"field","ip":["192.168.178.0/24"],"port":"5000","outboundTag":"direct","enabled":true},
+				{"type":"field","outboundTag":"blocked","ip":["geoip:private"]}
+			]
+		}
+	}`
+	out, err := EnsureDnsServerRouting(in)
+	if err != nil {
+		t.Fatalf("unexpected err: %v", err)
+	}
+	if out != in {
+		t.Fatalf("manual direct rule must survive the save untouched, got: %s", out)
+	}
+
+	// Same shape with a single literal private IP — indistinguishable from
+	// a legacy managed rule only if it matches a configured dns server,
+	// which it doesn't here, so it must survive too.
+	in2 := `{
+		"dns": {"servers": ["1.1.1.1"]},
+		"routing": {
+			"rules": [
+				{"type":"field","ip":["192.168.178.4"],"port":"5000","outboundTag":"direct"},
+				{"type":"field","outboundTag":"blocked","ip":["geoip:private"]}
+			]
+		}
+	}`
+	out2, err := EnsureDnsServerRouting(in2)
+	if err != nil {
+		t.Fatalf("unexpected err: %v", err)
+	}
+	if out2 != in2 {
+		t.Fatalf("manual single-IP direct rule must survive the save untouched, got: %s", out2)
+	}
+}
+
 func TestEnsureDnsServerRouting_DoesNotTouchManualRuleWithExtraMatchers(t *testing.T) {
 func TestEnsureDnsServerRouting_DoesNotTouchManualRuleWithExtraMatchers(t *testing.T) {
 	// A hand-written rule that also allows the DNS IP but carries an extra
 	// A hand-written rule that also allows the DNS IP but carries an extra
 	// matcher isn't recognized as "ours" and must be left alone; a fresh
 	// matcher isn't recognized as "ours" and must be left alone; a fresh

+ 1 - 0
internal/web/translation/ar-EG.json

@@ -1007,6 +1007,7 @@
       "apiToken": "رمز API",
       "apiToken": "رمز API",
       "apiTokenPlaceholder": "التوكن من صفحة إعدادات البانل البعيد",
       "apiTokenPlaceholder": "التوكن من صفحة إعدادات البانل البعيد",
       "apiTokenHint": "البانل البعيد بيعرض توكن API بتاعه في المصادقة → توكن API.",
       "apiTokenHint": "البانل البعيد بيعرض توكن API بتاعه في المصادقة → توكن API.",
+      "apiTokenKeepHint": "اتركه فارغًا للإبقاء على التوكن الحالي",
       "regenerate": "تجديد التوكن",
       "regenerate": "تجديد التوكن",
       "regenerateConfirm": "تجديد التوكن هيلغي التوكن الحالي. أي بانل مركزي بيستخدمه هيفقد الصلاحية لحد ما تحدّث التوكن. تكمّل؟",
       "regenerateConfirm": "تجديد التوكن هيلغي التوكن الحالي. أي بانل مركزي بيستخدمه هيفقد الصلاحية لحد ما تحدّث التوكن. تكمّل؟",
       "allowPrivateAddress": "السماح بالعنوان الخاص",
       "allowPrivateAddress": "السماح بالعنوان الخاص",

+ 1 - 0
internal/web/translation/en-US.json

@@ -1124,6 +1124,7 @@
       "apiToken": "API Token",
       "apiToken": "API Token",
       "apiTokenPlaceholder": "Token from the remote panel's Settings page",
       "apiTokenPlaceholder": "Token from the remote panel's Settings page",
       "apiTokenHint": "The remote panel exposes its API token under Authentication → API Token.",
       "apiTokenHint": "The remote panel exposes its API token under Authentication → API Token.",
+      "apiTokenKeepHint": "Leave blank to keep the current token",
       "regenerate": "Regenerate Token",
       "regenerate": "Regenerate Token",
       "regenerateConfirm": "Regenerating invalidates the current token. Any central panel using it will lose access until updated. Continue?",
       "regenerateConfirm": "Regenerating invalidates the current token. Any central panel using it will lose access until updated. Continue?",
       "allowPrivateAddress": "Allow private address",
       "allowPrivateAddress": "Allow private address",

+ 1 - 0
internal/web/translation/es-ES.json

@@ -1007,6 +1007,7 @@
       "apiToken": "Token API",
       "apiToken": "Token API",
       "apiTokenPlaceholder": "Token desde la página de Configuración del panel remoto",
       "apiTokenPlaceholder": "Token desde la página de Configuración del panel remoto",
       "apiTokenHint": "El panel remoto expone su token de API en Configuraciones de Seguridad → Token de API.",
       "apiTokenHint": "El panel remoto expone su token de API en Configuraciones de Seguridad → Token de API.",
+      "apiTokenKeepHint": "Déjalo en blanco para mantener el token actual",
       "regenerate": "Regenerar token",
       "regenerate": "Regenerar token",
       "regenerateConfirm": "Regenerar invalida el token actual. Cualquier panel central que lo use perderá el acceso hasta que se actualice. ¿Continuar?",
       "regenerateConfirm": "Regenerar invalida el token actual. Cualquier panel central que lo use perderá el acceso hasta que se actualice. ¿Continuar?",
       "allowPrivateAddress": "Permitir dirección privada",
       "allowPrivateAddress": "Permitir dirección privada",

+ 1 - 0
internal/web/translation/fa-IR.json

@@ -1007,6 +1007,7 @@
       "apiToken": "توکن API",
       "apiToken": "توکن API",
       "apiTokenPlaceholder": "توکن از صفحه تنظیمات پنل ریموت",
       "apiTokenPlaceholder": "توکن از صفحه تنظیمات پنل ریموت",
       "apiTokenHint": "پنل ریموت توکن API خودش را در بخش احرازهویت → توکن API نمایش می‌دهد.",
       "apiTokenHint": "پنل ریموت توکن API خودش را در بخش احرازهویت → توکن API نمایش می‌دهد.",
+      "apiTokenKeepHint": "برای حفظ توکن فعلی خالی بگذارید",
       "regenerate": "تولید مجدد توکن",
       "regenerate": "تولید مجدد توکن",
       "regenerateConfirm": "تولید مجدد، توکن فعلی را باطل می‌کند. هر پنل مرکزی‌ای که از این توکن استفاده می‌کند تا زمان به‌روزرسانی، دسترسی‌اش قطع می‌شود. ادامه می‌دهید؟",
       "regenerateConfirm": "تولید مجدد، توکن فعلی را باطل می‌کند. هر پنل مرکزی‌ای که از این توکن استفاده می‌کند تا زمان به‌روزرسانی، دسترسی‌اش قطع می‌شود. ادامه می‌دهید؟",
       "allowPrivateAddress": "اجازه آدرس خصوصی",
       "allowPrivateAddress": "اجازه آدرس خصوصی",

+ 1 - 0
internal/web/translation/id-ID.json

@@ -1007,6 +1007,7 @@
       "apiToken": "Token API",
       "apiToken": "Token API",
       "apiTokenPlaceholder": "Token dari halaman Pengaturan panel jarak jauh",
       "apiTokenPlaceholder": "Token dari halaman Pengaturan panel jarak jauh",
       "apiTokenHint": "Panel jarak jauh menampilkan token API-nya di Otentikasi → Token API.",
       "apiTokenHint": "Panel jarak jauh menampilkan token API-nya di Otentikasi → Token API.",
+      "apiTokenKeepHint": "Biarkan kosong untuk mempertahankan token saat ini",
       "regenerate": "Buat Ulang Token",
       "regenerate": "Buat Ulang Token",
       "regenerateConfirm": "Membuat ulang akan membatalkan token saat ini. Setiap panel pusat yang menggunakannya akan kehilangan akses sampai diperbarui. Lanjutkan?",
       "regenerateConfirm": "Membuat ulang akan membatalkan token saat ini. Setiap panel pusat yang menggunakannya akan kehilangan akses sampai diperbarui. Lanjutkan?",
       "allowPrivateAddress": "Izinkan alamat pribadi",
       "allowPrivateAddress": "Izinkan alamat pribadi",

+ 1 - 0
internal/web/translation/ja-JP.json

@@ -1007,6 +1007,7 @@
       "apiToken": "API トークン",
       "apiToken": "API トークン",
       "apiTokenPlaceholder": "リモートパネルの設定ページから取得したトークン",
       "apiTokenPlaceholder": "リモートパネルの設定ページから取得したトークン",
       "apiTokenHint": "リモートパネルでは、セキュリティ設定 → APIトークン でAPIトークンを確認できます。",
       "apiTokenHint": "リモートパネルでは、セキュリティ設定 → APIトークン でAPIトークンを確認できます。",
+      "apiTokenKeepHint": "現在のトークンを保持するには空欄のままにします",
       "regenerate": "トークンを再生成",
       "regenerate": "トークンを再生成",
       "regenerateConfirm": "再生成すると現在のトークンは無効になります。これを使用しているすべての中央パネルは更新されるまでアクセスできなくなります。続行しますか?",
       "regenerateConfirm": "再生成すると現在のトークンは無効になります。これを使用しているすべての中央パネルは更新されるまでアクセスできなくなります。続行しますか?",
       "allowPrivateAddress": "プライベートアドレスを許可",
       "allowPrivateAddress": "プライベートアドレスを許可",

+ 1 - 0
internal/web/translation/pt-BR.json

@@ -1007,6 +1007,7 @@
       "apiToken": "Token API",
       "apiToken": "Token API",
       "apiTokenPlaceholder": "Token da página de Configurações do painel remoto",
       "apiTokenPlaceholder": "Token da página de Configurações do painel remoto",
       "apiTokenHint": "O painel remoto exibe o token da API em Autenticação → Token da API.",
       "apiTokenHint": "O painel remoto exibe o token da API em Autenticação → Token da API.",
+      "apiTokenKeepHint": "Deixe em branco para manter o token atual",
       "regenerate": "Regenerar token",
       "regenerate": "Regenerar token",
       "regenerateConfirm": "Regenerar invalida o token atual. Qualquer painel central que o utilize perderá acesso até ser atualizado. Continuar?",
       "regenerateConfirm": "Regenerar invalida o token atual. Qualquer painel central que o utilize perderá acesso até ser atualizado. Continuar?",
       "allowPrivateAddress": "Permitir endereço privado",
       "allowPrivateAddress": "Permitir endereço privado",

+ 1 - 0
internal/web/translation/ru-RU.json

@@ -1007,6 +1007,7 @@
       "apiToken": "API Токен",
       "apiToken": "API Токен",
       "apiTokenPlaceholder": "Токен со страницы Настроек удалённой панели",
       "apiTokenPlaceholder": "Токен со страницы Настроек удалённой панели",
       "apiTokenHint": "Удалённая панель показывает свой токен API в разделе Учетная запись → Токен API.",
       "apiTokenHint": "Удалённая панель показывает свой токен API в разделе Учетная запись → Токен API.",
+      "apiTokenKeepHint": "Оставьте пустым, чтобы сохранить текущий токен",
       "regenerate": "Сгенерировать токен заново",
       "regenerate": "Сгенерировать токен заново",
       "regenerateConfirm": "Повторная генерация аннулирует текущий токен. Любая центральная панель, использующая его, потеряет доступ до обновления. Продолжить?",
       "regenerateConfirm": "Повторная генерация аннулирует текущий токен. Любая центральная панель, использующая его, потеряет доступ до обновления. Продолжить?",
       "allowPrivateAddress": "Разрешить частный адрес",
       "allowPrivateAddress": "Разрешить частный адрес",

+ 1 - 0
internal/web/translation/tr-TR.json

@@ -1007,6 +1007,7 @@
       "apiToken": "API Token",
       "apiToken": "API Token",
       "apiTokenPlaceholder": "Uzak panelin Ayarlar sayfasındaki token",
       "apiTokenPlaceholder": "Uzak panelin Ayarlar sayfasındaki token",
       "apiTokenHint": "Uzak panel API token'ını Kimlik Doğrulama → API Token altında gösterir.",
       "apiTokenHint": "Uzak panel API token'ını Kimlik Doğrulama → API Token altında gösterir.",
+      "apiTokenKeepHint": "Mevcut token'ı korumak için boş bırakın",
       "regenerate": "Token'ı Yeniden Oluştur",
       "regenerate": "Token'ı Yeniden Oluştur",
       "regenerateConfirm": "Yeniden oluşturmak mevcut token'ı geçersiz kılar. Onu kullanan tüm merkezi paneller, güncellenene kadar erişimini kaybeder. Devam edilsin mi?",
       "regenerateConfirm": "Yeniden oluşturmak mevcut token'ı geçersiz kılar. Onu kullanan tüm merkezi paneller, güncellenene kadar erişimini kaybeder. Devam edilsin mi?",
       "allowPrivateAddress": "Özel Adrese İzin Ver",
       "allowPrivateAddress": "Özel Adrese İzin Ver",

+ 1 - 0
internal/web/translation/uk-UA.json

@@ -1007,6 +1007,7 @@
       "apiToken": "API Токен",
       "apiToken": "API Токен",
       "apiTokenPlaceholder": "Токен зі сторінки Налаштувань віддаленої панелі",
       "apiTokenPlaceholder": "Токен зі сторінки Налаштувань віддаленої панелі",
       "apiTokenHint": "Віддалена панель показує свій токен API в Автентифікація → Токен API.",
       "apiTokenHint": "Віддалена панель показує свій токен API в Автентифікація → Токен API.",
+      "apiTokenKeepHint": "Залиште порожнім, щоб зберегти поточний токен",
       "regenerate": "Перегенерувати токен",
       "regenerate": "Перегенерувати токен",
       "regenerateConfirm": "Перегенерація скасовує поточний токен. Будь-яка центральна панель, що його використовує, втратить доступ до оновлення. Продовжити?",
       "regenerateConfirm": "Перегенерація скасовує поточний токен. Будь-яка центральна панель, що його використовує, втратить доступ до оновлення. Продовжити?",
       "allowPrivateAddress": "Дозволити приватну адресу",
       "allowPrivateAddress": "Дозволити приватну адресу",

+ 1 - 0
internal/web/translation/vi-VN.json

@@ -1007,6 +1007,7 @@
       "apiToken": "Token API",
       "apiToken": "Token API",
       "apiTokenPlaceholder": "Token từ trang Cài đặt của panel từ xa",
       "apiTokenPlaceholder": "Token từ trang Cài đặt của panel từ xa",
       "apiTokenHint": "Panel từ xa hiển thị token API tại Bảo mật → Token API.",
       "apiTokenHint": "Panel từ xa hiển thị token API tại Bảo mật → Token API.",
+      "apiTokenKeepHint": "Để trống để giữ token hiện tại",
       "regenerate": "Tạo lại token",
       "regenerate": "Tạo lại token",
       "regenerateConfirm": "Tạo lại sẽ vô hiệu hóa token hiện tại. Mọi panel trung tâm dùng nó sẽ mất quyền truy cập cho đến khi được cập nhật. Tiếp tục?",
       "regenerateConfirm": "Tạo lại sẽ vô hiệu hóa token hiện tại. Mọi panel trung tâm dùng nó sẽ mất quyền truy cập cho đến khi được cập nhật. Tiếp tục?",
       "allowPrivateAddress": "Cho phép địa chỉ riêng",
       "allowPrivateAddress": "Cho phép địa chỉ riêng",

+ 1 - 0
internal/web/translation/zh-CN.json

@@ -1007,6 +1007,7 @@
       "apiToken": "API 令牌",
       "apiToken": "API 令牌",
       "apiTokenPlaceholder": "远程面板设置页中的令牌",
       "apiTokenPlaceholder": "远程面板设置页中的令牌",
       "apiTokenHint": "远程面板在 安全设定 → API 令牌 中显示其 API 令牌。",
       "apiTokenHint": "远程面板在 安全设定 → API 令牌 中显示其 API 令牌。",
+      "apiTokenKeepHint": "留空以保留当前令牌",
       "regenerate": "重新生成令牌",
       "regenerate": "重新生成令牌",
       "regenerateConfirm": "重新生成会使当前令牌失效。任何使用该令牌的中央面板都会失去访问权限,直至更新。是否继续?",
       "regenerateConfirm": "重新生成会使当前令牌失效。任何使用该令牌的中央面板都会失去访问权限,直至更新。是否继续?",
       "allowPrivateAddress": "允许私有地址",
       "allowPrivateAddress": "允许私有地址",

+ 1 - 0
internal/web/translation/zh-TW.json

@@ -1007,6 +1007,7 @@
       "apiToken": "API 權杖",
       "apiToken": "API 權杖",
       "apiTokenPlaceholder": "遠端面板設定頁中的權杖",
       "apiTokenPlaceholder": "遠端面板設定頁中的權杖",
       "apiTokenHint": "遠端面板在 安全設定 → API 權杖 中顯示其 API 權杖。",
       "apiTokenHint": "遠端面板在 安全設定 → API 權杖 中顯示其 API 權杖。",
+      "apiTokenKeepHint": "留空以保留目前的權杖",
       "regenerate": "重新產生權杖",
       "regenerate": "重新產生權杖",
       "regenerateConfirm": "重新產生會使目前的權杖失效。任何使用該權杖的中央面板將失去存取權,直到更新為止。是否繼續?",
       "regenerateConfirm": "重新產生會使目前的權杖失效。任何使用該權杖的中央面板將失去存取權,直到更新為止。是否繼續?",
       "allowPrivateAddress": "允許私有地址",
       "allowPrivateAddress": "允許私有地址",

+ 4 - 0
internal/xray/api.go

@@ -176,6 +176,8 @@ func (x *XrayAPI) DelInbound(tag string) error {
 // startup — notably v26.7.11's refusal of unencrypted vless/trojan outbounds
 // startup — notably v26.7.11's refusal of unencrypted vless/trojan outbounds
 // whose server address is a public IP or domain.
 // whose server address is a public IP or domain.
 func ValidateOutboundConfig(outbound []byte) error {
 func ValidateOutboundConfig(outbound []byte) error {
+	ensureXrayAssetLocation()
+
 	detour := new(conf.OutboundDetourConfig)
 	detour := new(conf.OutboundDetourConfig)
 	if err := json.Unmarshal(outbound, detour); err != nil {
 	if err := json.Unmarshal(outbound, detour); err != nil {
 		return err
 		return err
@@ -191,6 +193,8 @@ func (x *XrayAPI) AddOutbound(outbound []byte) error {
 	}
 	}
 	client := *x.HandlerServiceClient
 	client := *x.HandlerServiceClient
 
 
+	ensureXrayAssetLocation()
+
 	conf := new(conf.OutboundDetourConfig)
 	conf := new(conf.OutboundDetourConfig)
 	if err := json.Unmarshal(outbound, conf); err != nil {
 	if err := json.Unmarshal(outbound, conf); err != nil {
 		logger.Debug("Failed to unmarshal outbound:", err)
 		logger.Debug("Failed to unmarshal outbound:", err)

+ 17 - 0
internal/xray/hot_diff.go

@@ -128,6 +128,10 @@ func diffInbounds(oldCfg, newCfg *Config, diff *HotDiff) bool {
 		if exists && diffInboundUsers(oldIb, newIb, diff) {
 		if exists && diffInboundUsers(oldIb, newIb, diff) {
 			continue
 			continue
 		}
 		}
+		if exists && (inboundUsesReality(oldIb) || inboundUsesReality(newIb)) {
+			logger.Debug("hot diff: inbound [", oldIb.Tag, "] REALITY configuration changed; a gRPC remove+add does not reliably rebuild the REALITY authenticator, forcing a full restart")
+			return false
+		}
 		diff.RemovedInboundTags = append(diff.RemovedInboundTags, oldIb.Tag)
 		diff.RemovedInboundTags = append(diff.RemovedInboundTags, oldIb.Tag)
 		if exists {
 		if exists {
 			raw, err := json.Marshal(newIb)
 			raw, err := json.Marshal(newIb)
@@ -247,6 +251,19 @@ func splitSettingsClients(raw json_util.RawMessage) (map[string]clientEntry, []b
 	return clients, rest, true
 	return clients, rest, true
 }
 }
 
 
+func inboundUsesReality(ib *InboundConfig) bool {
+	if ib == nil || len(ib.StreamSettings) == 0 {
+		return false
+	}
+	var stream struct {
+		Security string `json:"security"`
+	}
+	if err := json.Unmarshal(ib.StreamSettings, &stream); err != nil {
+		return false
+	}
+	return stream.Security == "reality"
+}
+
 func inboundHasReverseClient(ib *InboundConfig) bool {
 func inboundHasReverseClient(ib *InboundConfig) bool {
 	if ib == nil {
 	if ib == nil {
 		return false
 		return false

+ 42 - 0
internal/xray/hot_diff_test.go

@@ -343,3 +343,45 @@ func TestComputeHotDiff_RoutingStrategyChangeNeedsRestart(t *testing.T) {
 		t.Fatal("domainStrategy change must force a restart")
 		t.Fatal("domainStrategy change must force a restart")
 	}
 	}
 }
 }
+
+func TestComputeHotDiff_RealityStreamChangeNeedsRestart(t *testing.T) {
+	oldCfg := makeHotConfig()
+	oldCfg.InboundConfigs[1].StreamSettings = json_util.RawMessage(`{"network":"tcp","security":"reality","realitySettings":{"privateKey":"old-key","serverNames":["a.example"]}}`)
+	newCfg := makeHotConfig()
+	newCfg.InboundConfigs[1].StreamSettings = json_util.RawMessage(`{"network":"tcp","security":"reality","realitySettings":{"privateKey":"new-key","serverNames":["a.example"]}}`)
+
+	if _, ok := ComputeHotDiff(oldCfg, newCfg); ok {
+		t.Fatal("a REALITY stream-settings change must force a full restart, not a gRPC hot swap")
+	}
+}
+
+func TestComputeHotDiff_SecuritySwitchToRealityNeedsRestart(t *testing.T) {
+	oldCfg := makeHotConfig()
+	oldCfg.InboundConfigs[1].StreamSettings = json_util.RawMessage(`{"network":"tcp","security":"none"}`)
+	newCfg := makeHotConfig()
+	newCfg.InboundConfigs[1].StreamSettings = json_util.RawMessage(`{"network":"tcp","security":"reality","realitySettings":{"privateKey":"k"}}`)
+
+	if _, ok := ComputeHotDiff(oldCfg, newCfg); ok {
+		t.Fatal("switching security to REALITY must force a full restart")
+	}
+}
+
+func TestComputeHotDiff_RealityClientOnlyChangeStaysHot(t *testing.T) {
+	oldCfg := makeHotConfig()
+	oldCfg.InboundConfigs[1].StreamSettings = json_util.RawMessage(`{"network":"tcp","security":"reality","realitySettings":{"privateKey":"k"}}`)
+	oldCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[{"email":"a","id":"uuid-a"}],"decryption":"none"}`)
+	newCfg := makeHotConfig()
+	newCfg.InboundConfigs[1].StreamSettings = json_util.RawMessage(`{"network":"tcp","security":"reality","realitySettings":{"privateKey":"k"}}`)
+	newCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[{"email":"a","id":"uuid-a"},{"email":"b","id":"uuid-b"}],"decryption":"none"}`)
+
+	diff, ok := ComputeHotDiff(oldCfg, newCfg)
+	if !ok {
+		t.Fatal("client-only change on a REALITY inbound must stay hot-appliable")
+	}
+	if len(diff.RemovedInboundTags) != 0 || len(diff.AddedInbounds) != 0 {
+		t.Fatalf("client-only change must not replace the handler, got %+v", diff)
+	}
+	if len(diff.AddedUsers) != 1 || diff.AddedUsers[0].Email != "b" {
+		t.Fatalf("expected user b added via AlterInbound, got %+v", diff.AddedUsers)
+	}
+}

+ 2 - 0
tools/openapigen/main.go

@@ -78,6 +78,8 @@ func run(root, outDir string) error {
 			Path: resolveRel(root, "internal/web/service"),
 			Path: resolveRel(root, "internal/web/service"),
 			StructAllow: setOf(
 			StructAllow: setOf(
 				"InboundOption",
 				"InboundOption",
+				"NodeMutationRequest",
+				"NodeView",
 				"ProbeResultUI",
 				"ProbeResultUI",
 				"RealityScanResult",
 				"RealityScanResult",
 			),
 			),

+ 2 - 0
x-ui.service.arch

@@ -2,6 +2,8 @@
 Description=x-ui Service
 Description=x-ui Service
 After=network.target
 After=network.target
 Wants=network.target
 Wants=network.target
+StartLimitIntervalSec=180
+StartLimitBurst=10
 
 
 [Service]
 [Service]
 EnvironmentFile=-/etc/conf.d/x-ui
 EnvironmentFile=-/etc/conf.d/x-ui

+ 2 - 0
x-ui.service.debian

@@ -2,6 +2,8 @@
 Description=x-ui Service
 Description=x-ui Service
 After=network.target
 After=network.target
 Wants=network.target
 Wants=network.target
+StartLimitIntervalSec=180
+StartLimitBurst=10
 
 
 [Service]
 [Service]
 EnvironmentFile=-/etc/default/x-ui
 EnvironmentFile=-/etc/default/x-ui

+ 2 - 0
x-ui.service.rhel

@@ -2,6 +2,8 @@
 Description=x-ui Service
 Description=x-ui Service
 After=network.target
 After=network.target
 Wants=network.target
 Wants=network.target
+StartLimitIntervalSec=180
+StartLimitBurst=10
 
 
 [Service]
 [Service]
 EnvironmentFile=-/etc/sysconfig/x-ui
 EnvironmentFile=-/etc/sysconfig/x-ui