Browse Source

fix(nodes): make node API tokens write-only (#5613)

* fix(nodes): make node API tokens write-only

* fix(nodes): keep token optional on edit for write-only API tokens

NodeView no longer returns apiToken, so the edit form must consume hasApiToken and not require re-entering the token. Relaxes the form validation on edit, adds a keep-current placeholder, and adds the i18n key to all 13 locales.
n0ctal 6 hours ago
parent
commit
c77608bc47

+ 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(),

+ 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',

+ 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

+ 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'],

+ 1 - 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"`

+ 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)
+	}
+}

+ 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

+ 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": "允許私有地址",

+ 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",
 			),
 			),