Просмотр исходного кода

fix(nodes): apply a rotated master mTLS certificate without restarting the panel (#6194)

* fix(mtls): invalidate pooled clients after credential rotation

* fix(mtls): make connection reload read-only

---------

Co-authored-by: n0ctal <[email protected]>
n0ctal 6 часов назад
Родитель
Сommit
7ecd88b9e3

+ 56 - 88
docs/content/docs/en/reference/api/nodes.mdx

@@ -1,51 +1,40 @@
 ---
 ---
 title: Nodes
 title: Nodes
-description: >-
-  Manage remote 3x-ui panels acting as nodes for a central panel. All endpoints
-  under /panel/api/nodes.
+description: Manage remote 3x-ui panels acting as nodes for a central panel. All
+  endpoints under /panel/api/nodes.
 full: true
 full: true
 _openapi:
 _openapi:
   preload:
   preload:
     - ./public/openapi.json
     - ./public/openapi.json
   toc:
   toc:
     - depth: 2
     - depth: 2
-      title: >-
-        List every configured node with its connection details, health, and last
+      title: List every configured node with its connection details, health, and last
         heartbeat patch.
         heartbeat patch.
-      url: >-
-        #list-every-configured-node-with-its-connection-details-health-and-last-heartbeat-patch
+      url: '#list-every-configured-node-with-its-connection-details-health-and-last-heartbeat-patch'
     - depth: 2
     - depth: 2
-      title: >-
-        This panel's node-auth CA certificate (public, PEM) to paste into a
+      title: This panel's node-auth CA certificate (public, PEM) to paste into a
         node's mTLS trust setting. Lazily mints the CA and the master client
         node's mTLS trust setting. Lazily mints the CA and the master client
         cert on first call. Pair with setting tlsVerifyMode=mtls on the node.
         cert on first call. Pair with setting tlsVerifyMode=mtls on the node.
-      url: >-
-        #this-panels-node-auth-ca-certificate-public-pem-to-paste-into-a-nodes-mtls-trust-setting-lazily-mints-the-ca-and-the-master-client-cert-on-first-call-pair-with-setting-tlsverifymodemtls-on-the-node
+      url: '#this-panels-node-auth-ca-certificate-public-pem-to-paste-into-a-nodes-mtls-trust-setting-lazily-mints-the-ca-and-the-master-client-cert-on-first-call-pair-with-setting-tlsverifymodemtls-on-the-node'
     - depth: 2
     - depth: 2
-      title: >-
-        Set the CA certificate this panel trusts for incoming node-API client
+      title: Set the CA certificate this panel trusts for incoming node-API client
         certificates (this panel acting as a node). Paste the managing panel's
         certificates (this panel acting as a node). Paste the managing panel's
         CA (from nodes/mtls/ca). An empty caCert disables it. A non-empty value
         CA (from nodes/mtls/ca). An empty caCert disables it. A non-empty value
         must be a PEM certificate. Applied on the next panel restart.
         must be a PEM certificate. Applied on the next panel restart.
-      url: >-
-        #set-the-ca-certificate-this-panel-trusts-for-incoming-node-api-client-certificates-this-panel-acting-as-a-node-paste-the-managing-panels-ca-from-nodesmtlsca-an-empty-cacert-disables-it-a-non-empty-value-must-be-a-pem-certificate-applied-on-the-next-panel-restart
+      url: '#set-the-ca-certificate-this-panel-trusts-for-incoming-node-api-client-certificates-this-panel-acting-as-a-node-paste-the-managing-panels-ca-from-nodesmtlsca-an-empty-cacert-disables-it-a-non-empty-value-must-be-a-pem-certificate-applied-on-the-next-panel-restart'
     - depth: 2
     - depth: 2
       title: Fetch a single node by ID.
       title: Fetch a single node by ID.
       url: '#fetch-a-single-node-by-id'
       url: '#fetch-a-single-node-by-id'
     - depth: 2
     - depth: 2
-      title: >-
-        Fetch a node's own web TLS certificate/key file paths (proxied to the
+      title: Fetch a node's own web TLS certificate/key file paths (proxied to the
         node). Used by the inbound form's "Set Cert from Panel" so a
         node). Used by the inbound form's "Set Cert from Panel" so a
         node-assigned inbound gets paths that exist on the node, not the central
         node-assigned inbound gets paths that exist on the node, not the central
         panel.
         panel.
-      url: >-
-        #fetch-a-nodes-own-web-tls-certificatekey-file-paths-proxied-to-the-node-used-by-the-inbound-forms-set-cert-from-panel-so-a-node-assigned-inbound-gets-paths-that-exist-on-the-node-not-the-central-panel
+      url: '#fetch-a-nodes-own-web-tls-certificatekey-file-paths-proxied-to-the-node-used-by-the-inbound-forms-set-cert-from-panel-so-a-node-assigned-inbound-gets-paths-that-exist-on-the-node-not-the-central-panel'
     - depth: 2
     - depth: 2
-      title: >-
-        Register a new remote node. Provide its URL, apiToken, and optional
+      title: Register a new remote node. Provide its URL, apiToken, and optional
         remark / allowPrivateAddress flag.
         remark / allowPrivateAddress flag.
-      url: >-
-        #register-a-new-remote-node-provide-its-url-apitoken-and-optional-remark--allowprivateaddress-flag
+      url: '#register-a-new-remote-node-provide-its-url-apitoken-and-optional-remark--allowprivateaddress-flag'
     - depth: 2
     - depth: 2
       title: Replace a node’s connection details. Same body shape as /add.
       title: Replace a node’s connection details. Same body shape as /add.
       url: '#replace-a-nodes-connection-details-same-body-shape-as-add'
       url: '#replace-a-nodes-connection-details-same-body-shape-as-add'
@@ -56,115 +45,94 @@ _openapi:
       title: Pause or resume traffic sync with this node.
       title: Pause or resume traffic sync with this node.
       url: '#pause-or-resume-traffic-sync-with-this-node'
       url: '#pause-or-resume-traffic-sync-with-this-node'
     - depth: 2
     - depth: 2
-      title: >-
-        Probe a node without saving it. Uses the body as connection details and
+      title: Probe a node without saving it. Uses the body as connection details and
         returns the same heartbeat snapshot a registered node would have.
         returns the same heartbeat snapshot a registered node would have.
-      url: >-
-        #probe-a-node-without-saving-it-uses-the-body-as-connection-details-and-returns-the-same-heartbeat-snapshot-a-registered-node-would-have
+      url: '#probe-a-node-without-saving-it-uses-the-body-as-connection-details-and-returns-the-same-heartbeat-snapshot-a-registered-node-would-have'
     - depth: 2
     - depth: 2
-      title: >-
-        Connect to the node over HTTPS without verifying its certificate and
+      title: Connect to the node over HTTPS without verifying its certificate and
         return the leaf certificate's SHA-256 (base64). Used by the Add/Edit
         return the leaf certificate's SHA-256 (base64). Used by the Add/Edit
         Node dialog to fetch and pin a self-signed certificate. Uses the same
         Node dialog to fetch and pin a self-signed certificate. Uses the same
         body as /test.
         body as /test.
-      url: >-
-        #connect-to-the-node-over-https-without-verifying-its-certificate-and-return-the-leaf-certificates-sha-256-base64-used-by-the-addedit-node-dialog-to-fetch-and-pin-a-self-signed-certificate-uses-the-same-body-as-test
+      url: '#connect-to-the-node-over-https-without-verifying-its-certificate-and-return-the-leaf-certificates-sha-256-base64-used-by-the-addedit-node-dialog-to-fetch-and-pin-a-self-signed-certificate-uses-the-same-body-as-test'
     - depth: 2
     - depth: 2
-      title: >-
-        Use unsaved node connection details to list the remote inbounds
-        available for selective import.
-      url: >-
-        #use-unsaved-node-connection-details-to-list-the-remote-inbounds-available-for-selective-import
+      title: Use unsaved node connection details to list the remote inbounds available
+        for selective import.
+      url: '#use-unsaved-node-connection-details-to-list-the-remote-inbounds-available-for-selective-import'
     - depth: 2
     - depth: 2
       title: Probe an existing node, updating its cached health state.
       title: Probe an existing node, updating its cached health state.
       url: '#probe-an-existing-node-updating-its-cached-health-state'
       url: '#probe-an-existing-node-updating-its-cached-health-state'
     - depth: 2
     - depth: 2
-      title: >-
-        Trigger the official panel self-updater on each given node (downloads
+      title: 'Trigger the official panel self-updater on each given node (downloads
         the latest release and restarts). Only enabled, online nodes are
         the latest release and restarts). Only enabled, online nodes are
         updated; offline/disabled ones are reported as skipped. Set "dev": true
         updated; offline/disabled ones are reported as skipped. Set "dev": true
         to move the nodes to the rolling per-commit dev channel instead of the
         to move the nodes to the rolling per-commit dev channel instead of the
-        latest stable release. Returns a per-node result list.
-      url: >-
-        #trigger-the-official-panel-self-updater-on-each-given-node-downloads-the-latest-release-and-restarts-only-enabled-online-nodes-are-updated-offlinedisabled-ones-are-reported-as-skipped-set-dev-true-to-move-the-nodes-to-the-rolling-per-commit-dev-channel-instead-of-the-latest-stable-release-returns-a-per-node-result-list
+        latest stable release. Returns a per-node result list.'
+      url: '#trigger-the-official-panel-self-updater-on-each-given-node-downloads-the-latest-release-and-restarts-only-enabled-online-nodes-are-updated-offlinedisabled-ones-are-reported-as-skipped-set-dev-true-to-move-the-nodes-to-the-rolling-per-commit-dev-channel-instead-of-the-latest-stable-release-returns-a-per-node-result-list'
     - depth: 2
     - depth: 2
-      title: >-
-        Aggregated metric history for a node — same shape as /server/history,
+      title: Aggregated metric history for a node — same shape as /server/history,
         scoped to one node.
         scoped to one node.
-      url: >-
-        #aggregated-metric-history-for-a-node--same-shape-as-serverhistory-scoped-to-one-node
+      url: '#aggregated-metric-history-for-a-node--same-shape-as-serverhistory-scoped-to-one-node'
+    - depth: 2
+      title: Validate the stored master mTLS client credential and invalidate cached
+        transports. Each transport closes its old idle pool and rebuilds with
+        the rotated certificate before its next request.
+      url: '#validate-the-stored-master-mtls-client-credential-and-invalidate-cached-transports-each-transport-closes-its-old-idle-pool-and-rebuilds-with-the-rotated-certificate-before-its-next-request'
   structuredData:
   structuredData:
     headings:
     headings:
-      - content: >-
-          List every configured node with its connection details, health, and
+      - content: List every configured node with its connection details, health, and
           last heartbeat patch.
           last heartbeat patch.
-        id: >-
-          list-every-configured-node-with-its-connection-details-health-and-last-heartbeat-patch
-      - content: >-
-          This panel's node-auth CA certificate (public, PEM) to paste into a
+        id: list-every-configured-node-with-its-connection-details-health-and-last-heartbeat-patch
+      - content: This panel's node-auth CA certificate (public, PEM) to paste into a
           node's mTLS trust setting. Lazily mints the CA and the master client
           node's mTLS trust setting. Lazily mints the CA and the master client
           cert on first call. Pair with setting tlsVerifyMode=mtls on the node.
           cert on first call. Pair with setting tlsVerifyMode=mtls on the node.
-        id: >-
-          this-panels-node-auth-ca-certificate-public-pem-to-paste-into-a-nodes-mtls-trust-setting-lazily-mints-the-ca-and-the-master-client-cert-on-first-call-pair-with-setting-tlsverifymodemtls-on-the-node
-      - content: >-
-          Set the CA certificate this panel trusts for incoming node-API client
+        id: this-panels-node-auth-ca-certificate-public-pem-to-paste-into-a-nodes-mtls-trust-setting-lazily-mints-the-ca-and-the-master-client-cert-on-first-call-pair-with-setting-tlsverifymodemtls-on-the-node
+      - content: Set the CA certificate this panel trusts for incoming node-API client
           certificates (this panel acting as a node). Paste the managing panel's
           certificates (this panel acting as a node). Paste the managing panel's
           CA (from nodes/mtls/ca). An empty caCert disables it. A non-empty
           CA (from nodes/mtls/ca). An empty caCert disables it. A non-empty
           value must be a PEM certificate. Applied on the next panel restart.
           value must be a PEM certificate. Applied on the next panel restart.
-        id: >-
-          set-the-ca-certificate-this-panel-trusts-for-incoming-node-api-client-certificates-this-panel-acting-as-a-node-paste-the-managing-panels-ca-from-nodesmtlsca-an-empty-cacert-disables-it-a-non-empty-value-must-be-a-pem-certificate-applied-on-the-next-panel-restart
+        id: set-the-ca-certificate-this-panel-trusts-for-incoming-node-api-client-certificates-this-panel-acting-as-a-node-paste-the-managing-panels-ca-from-nodesmtlsca-an-empty-cacert-disables-it-a-non-empty-value-must-be-a-pem-certificate-applied-on-the-next-panel-restart
       - content: Fetch a single node by ID.
       - content: Fetch a single node by ID.
         id: fetch-a-single-node-by-id
         id: fetch-a-single-node-by-id
-      - content: >-
-          Fetch a node's own web TLS certificate/key file paths (proxied to the
+      - content: Fetch a node's own web TLS certificate/key file paths (proxied to the
           node). Used by the inbound form's "Set Cert from Panel" so a
           node). Used by the inbound form's "Set Cert from Panel" so a
           node-assigned inbound gets paths that exist on the node, not the
           node-assigned inbound gets paths that exist on the node, not the
           central panel.
           central panel.
-        id: >-
-          fetch-a-nodes-own-web-tls-certificatekey-file-paths-proxied-to-the-node-used-by-the-inbound-forms-set-cert-from-panel-so-a-node-assigned-inbound-gets-paths-that-exist-on-the-node-not-the-central-panel
-      - content: >-
-          Register a new remote node. Provide its URL, apiToken, and optional
+        id: fetch-a-nodes-own-web-tls-certificatekey-file-paths-proxied-to-the-node-used-by-the-inbound-forms-set-cert-from-panel-so-a-node-assigned-inbound-gets-paths-that-exist-on-the-node-not-the-central-panel
+      - content: Register a new remote node. Provide its URL, apiToken, and optional
           remark / allowPrivateAddress flag.
           remark / allowPrivateAddress flag.
-        id: >-
-          register-a-new-remote-node-provide-its-url-apitoken-and-optional-remark--allowprivateaddress-flag
+        id: register-a-new-remote-node-provide-its-url-apitoken-and-optional-remark--allowprivateaddress-flag
       - content: Replace a node’s connection details. Same body shape as /add.
       - content: Replace a node’s connection details. Same body shape as /add.
         id: replace-a-nodes-connection-details-same-body-shape-as-add
         id: replace-a-nodes-connection-details-same-body-shape-as-add
       - content: Delete a node. Inbounds bound to it are not auto-migrated.
       - content: Delete a node. Inbounds bound to it are not auto-migrated.
         id: delete-a-node-inbounds-bound-to-it-are-not-auto-migrated
         id: delete-a-node-inbounds-bound-to-it-are-not-auto-migrated
       - content: Pause or resume traffic sync with this node.
       - content: Pause or resume traffic sync with this node.
         id: pause-or-resume-traffic-sync-with-this-node
         id: pause-or-resume-traffic-sync-with-this-node
-      - content: >-
-          Probe a node without saving it. Uses the body as connection details
-          and returns the same heartbeat snapshot a registered node would have.
-        id: >-
-          probe-a-node-without-saving-it-uses-the-body-as-connection-details-and-returns-the-same-heartbeat-snapshot-a-registered-node-would-have
-      - content: >-
-          Connect to the node over HTTPS without verifying its certificate and
+      - content: Probe a node without saving it. Uses the body as connection details and
+          returns the same heartbeat snapshot a registered node would have.
+        id: probe-a-node-without-saving-it-uses-the-body-as-connection-details-and-returns-the-same-heartbeat-snapshot-a-registered-node-would-have
+      - content: Connect to the node over HTTPS without verifying its certificate and
           return the leaf certificate's SHA-256 (base64). Used by the Add/Edit
           return the leaf certificate's SHA-256 (base64). Used by the Add/Edit
           Node dialog to fetch and pin a self-signed certificate. Uses the same
           Node dialog to fetch and pin a self-signed certificate. Uses the same
           body as /test.
           body as /test.
-        id: >-
-          connect-to-the-node-over-https-without-verifying-its-certificate-and-return-the-leaf-certificates-sha-256-base64-used-by-the-addedit-node-dialog-to-fetch-and-pin-a-self-signed-certificate-uses-the-same-body-as-test
-      - content: >-
-          Use unsaved node connection details to list the remote inbounds
+        id: connect-to-the-node-over-https-without-verifying-its-certificate-and-return-the-leaf-certificates-sha-256-base64-used-by-the-addedit-node-dialog-to-fetch-and-pin-a-self-signed-certificate-uses-the-same-body-as-test
+      - content: Use unsaved node connection details to list the remote inbounds
           available for selective import.
           available for selective import.
-        id: >-
-          use-unsaved-node-connection-details-to-list-the-remote-inbounds-available-for-selective-import
+        id: use-unsaved-node-connection-details-to-list-the-remote-inbounds-available-for-selective-import
       - content: Probe an existing node, updating its cached health state.
       - content: Probe an existing node, updating its cached health state.
         id: probe-an-existing-node-updating-its-cached-health-state
         id: probe-an-existing-node-updating-its-cached-health-state
-      - content: >-
-          Trigger the official panel self-updater on each given node (downloads
+      - content: 'Trigger the official panel self-updater on each given node (downloads
           the latest release and restarts). Only enabled, online nodes are
           the latest release and restarts). Only enabled, online nodes are
           updated; offline/disabled ones are reported as skipped. Set "dev":
           updated; offline/disabled ones are reported as skipped. Set "dev":
           true to move the nodes to the rolling per-commit dev channel instead
           true to move the nodes to the rolling per-commit dev channel instead
-          of the latest stable release. Returns a per-node result list.
-        id: >-
-          trigger-the-official-panel-self-updater-on-each-given-node-downloads-the-latest-release-and-restarts-only-enabled-online-nodes-are-updated-offlinedisabled-ones-are-reported-as-skipped-set-dev-true-to-move-the-nodes-to-the-rolling-per-commit-dev-channel-instead-of-the-latest-stable-release-returns-a-per-node-result-list
-      - content: >-
-          Aggregated metric history for a node — same shape as /server/history,
+          of the latest stable release. Returns a per-node result list.'
+        id: trigger-the-official-panel-self-updater-on-each-given-node-downloads-the-latest-release-and-restarts-only-enabled-online-nodes-are-updated-offlinedisabled-ones-are-reported-as-skipped-set-dev-true-to-move-the-nodes-to-the-rolling-per-commit-dev-channel-instead-of-the-latest-stable-release-returns-a-per-node-result-list
+      - content: Aggregated metric history for a node — same shape as /server/history,
           scoped to one node.
           scoped to one node.
-        id: >-
-          aggregated-metric-history-for-a-node--same-shape-as-serverhistory-scoped-to-one-node
+        id: aggregated-metric-history-for-a-node--same-shape-as-serverhistory-scoped-to-one-node
+      - content: Validate the stored master mTLS client credential and invalidate cached
+          transports. Each transport closes its old idle pool and rebuilds with
+          the rotated certificate before its next request.
+        id: validate-the-stored-master-mtls-client-credential-and-invalidate-cached-transports-each-transport-closes-its-old-idle-pool-and-rebuilds-with-the-rotated-certificate-before-its-next-request
     contents: []
     contents: []
 ---
 ---
 
 
@@ -177,7 +145,7 @@ export default function Layout(props) {
   return (
   return (
     <>
     <>
       {props.children}
       {props.children}
-      <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/nodes/list","method":"get"},{"path":"/panel/api/nodes/mtls/ca","method":"post"},{"path":"/panel/api/nodes/mtls/trustCA","method":"post"},{"path":"/panel/api/nodes/get/{id}","method":"get"},{"path":"/panel/api/nodes/webCert/{id}","method":"get"},{"path":"/panel/api/nodes/add","method":"post"},{"path":"/panel/api/nodes/update/{id}","method":"post"},{"path":"/panel/api/nodes/del/{id}","method":"post"},{"path":"/panel/api/nodes/setEnable/{id}","method":"post"},{"path":"/panel/api/nodes/test","method":"post"},{"path":"/panel/api/nodes/certFingerprint","method":"post"},{"path":"/panel/api/nodes/inbounds","method":"post"},{"path":"/panel/api/nodes/probe/{id}","method":"post"},{"path":"/panel/api/nodes/updatePanel","method":"post"},{"path":"/panel/api/nodes/history/{id}/{metric}/{bucket}","method":"get"}]} showTitle />
+      <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/nodes/list","method":"get"},{"path":"/panel/api/nodes/mtls/ca","method":"post"},{"path":"/panel/api/nodes/mtls/trustCA","method":"post"},{"path":"/panel/api/nodes/get/{id}","method":"get"},{"path":"/panel/api/nodes/webCert/{id}","method":"get"},{"path":"/panel/api/nodes/add","method":"post"},{"path":"/panel/api/nodes/update/{id}","method":"post"},{"path":"/panel/api/nodes/del/{id}","method":"post"},{"path":"/panel/api/nodes/setEnable/{id}","method":"post"},{"path":"/panel/api/nodes/test","method":"post"},{"path":"/panel/api/nodes/certFingerprint","method":"post"},{"path":"/panel/api/nodes/inbounds","method":"post"},{"path":"/panel/api/nodes/probe/{id}","method":"post"},{"path":"/panel/api/nodes/updatePanel","method":"post"},{"path":"/panel/api/nodes/history/{id}/{metric}/{bucket}","method":"get"},{"path":"/panel/api/nodes/mtls/reloadClient","method":"post"}]} showTitle />
     </>
     </>
   );
   );
 }
 }

+ 30 - 0
docs/public/openapi.json

@@ -10170,6 +10170,36 @@
           }
           }
         }
         }
       }
       }
+    },
+    "/panel/api/nodes/mtls/reloadClient": {
+      "post": {
+        "tags": [
+          "Nodes"
+        ],
+        "summary": "Validate the stored master mTLS client credential and invalidate cached transports. Each transport closes its old idle pool and rebuilds with the rotated certificate before its next request.",
+        "operationId": "post_panel_api_nodes_mtls_reloadClient",
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
     }
     }
   }
   }
 }
 }

+ 30 - 0
frontend/public/openapi.json

@@ -8086,6 +8086,36 @@
         }
         }
       }
       }
     },
     },
+    "/panel/api/nodes/mtls/reloadClient": {
+      "post": {
+        "tags": [
+          "Nodes"
+        ],
+        "summary": "Validate the stored master mTLS client credential and invalidate cached transports. Each transport closes its old idle pool and rebuilds with the rotated certificate before its next request.",
+        "operationId": "post_panel_api_nodes_mtls_reloadClient",
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/nodes/get/{id}": {
     "/panel/api/nodes/get/{id}": {
       "get": {
       "get": {
         "tags": [
         "tags": [

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

@@ -935,6 +935,11 @@ export const sections: readonly Section[] = [
         summary: "Set the CA certificate this panel trusts for incoming node-API client certificates (this panel acting as a node). Paste the managing panel's CA (from nodes/mtls/ca). An empty caCert disables it. A non-empty value must be a PEM certificate. Applied on the next panel restart.",
         summary: "Set the CA certificate this panel trusts for incoming node-API client certificates (this panel acting as a node). Paste the managing panel's CA (from nodes/mtls/ca). An empty caCert disables it. A non-empty value must be a PEM certificate. Applied on the next panel restart.",
         body: '{\n  "caCert": "-----BEGIN CERTIFICATE-----\\n...\\n-----END CERTIFICATE-----\\n"\n}',
         body: '{\n  "caCert": "-----BEGIN CERTIFICATE-----\\n...\\n-----END CERTIFICATE-----\\n"\n}',
       },
       },
+      {
+        method: 'POST',
+        path: '/panel/api/nodes/mtls/reloadClient',
+        summary: 'Validate the stored master mTLS client credential and invalidate cached transports. Each transport closes its old idle pool and rebuilds with the rotated certificate before its next request.',
+      },
       {
       {
         method: 'GET',
         method: 'GET',
         path: '/panel/api/nodes/get/:id',
         path: '/panel/api/nodes/get/:id',

+ 11 - 0
internal/web/controller/node.go

@@ -44,6 +44,17 @@ func (a *NodeController) initRouter(g *gin.RouterGroup) {
 	g.GET("/history/:id/:metric/:bucket", a.history)
 	g.GET("/history/:id/:metric/:bucket", a.history)
 	g.POST("/mtls/ca", a.mtlsCa)
 	g.POST("/mtls/ca", a.mtlsCa)
 	g.POST("/mtls/trustCA", a.setMtlsTrustCA)
 	g.POST("/mtls/trustCA", a.setMtlsTrustCA)
+	g.POST("/mtls/reloadClient", a.reloadMtlsClient)
+}
+
+// reloadMtlsClient validates the credential currently stored by the master and
+// closes cached mTLS pools so subsequent node requests present the new leaf.
+func (a *NodeController) reloadMtlsClient(c *gin.Context) {
+	if err := a.nodeService.ReloadMasterMtlsClient(); err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.reloadMtls"), err)
+		return
+	}
+	jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.reloadMtls"), nil)
 }
 }
 
 
 // mtlsCa returns this panel's node-auth CA certificate (public) to paste into a
 // mtlsCa returns this panel's node-auth CA certificate (public) to paste into a

+ 131 - 0
internal/web/runtime/tls_client.go

@@ -9,6 +9,7 @@ import (
 	"net/http"
 	"net/http"
 	"strings"
 	"strings"
 	"sync"
 	"sync"
+	"sync/atomic"
 	"time"
 	"time"
 
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
@@ -25,6 +26,7 @@ type MasterClientCertProvider func() (tls.Certificate, error)
 var (
 var (
 	masterClientCertMu sync.RWMutex
 	masterClientCertMu sync.RWMutex
 	masterClientCert   MasterClientCertProvider
 	masterClientCert   MasterClientCertProvider
+	masterCertEpoch    atomic.Uint64
 )
 )
 
 
 // SetMasterClientCertProvider installs the provider used to obtain the master
 // SetMasterClientCertProvider installs the provider used to obtain the master
@@ -45,6 +47,91 @@ func getMasterClientCert() (tls.Certificate, error) {
 	return p()
 	return p()
 }
 }
 
 
+// InvalidateMasterClientConnections advances the client-credential generation.
+// Every cached mTLS transport observes the generation before its next request,
+// replaces its TLS transport, and closes the old idle pool. Requests already
+// in flight are not interrupted; no request that starts after invalidation can
+// reuse a connection authenticated with the previous leaf.
+func InvalidateMasterClientConnections() {
+	masterCertEpoch.Add(1)
+}
+
+// ReloadMasterClientConnections validates that the currently configured
+// provider can load the master credential, then invalidates every cached mTLS
+// transport. Operators that rotate the credential outside the process (for
+// example by restoring settings) can call this without restarting the panel.
+func ReloadMasterClientConnections() error {
+	if _, err := getMasterClientCert(); err != nil {
+		return err
+	}
+	InvalidateMasterClientConnections()
+	return nil
+}
+
+type idleClosingRoundTripper interface {
+	http.RoundTripper
+	CloseIdleConnections()
+}
+
+type credentialRotatingTransport struct {
+	mu         sync.Mutex
+	generation uint64
+	current    idleClosingRoundTripper
+	build      func() (idleClosingRoundTripper, error)
+}
+
+func buildStableCredentialTransport(build func() (idleClosingRoundTripper, error)) (idleClosingRoundTripper, uint64, error) {
+	for {
+		before := masterCertEpoch.Load()
+		current, err := build()
+		if err != nil {
+			return nil, 0, err
+		}
+		after := masterCertEpoch.Load()
+		if before == after {
+			return current, after, nil
+		}
+		current.CloseIdleConnections()
+	}
+}
+
+func newCredentialRotatingTransport(build func() (idleClosingRoundTripper, error)) (*credentialRotatingTransport, error) {
+	current, generation, err := buildStableCredentialTransport(build)
+	if err != nil {
+		return nil, err
+	}
+	return &credentialRotatingTransport{
+		generation: generation,
+		current:    current,
+		build:      build,
+	}, nil
+}
+
+func (t *credentialRotatingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+	t.mu.Lock()
+	if masterCertEpoch.Load() != t.generation {
+		next, generation, err := buildStableCredentialTransport(t.build)
+		if err != nil {
+			t.mu.Unlock()
+			return nil, err
+		}
+		previous := t.current
+		t.current = next
+		t.generation = generation
+		previous.CloseIdleConnections()
+	}
+	current := t.current
+	t.mu.Unlock()
+	return current.RoundTrip(req)
+}
+
+func (t *credentialRotatingTransport) CloseIdleConnections() {
+	t.mu.Lock()
+	current := t.current
+	t.mu.Unlock()
+	current.CloseIdleConnections()
+}
+
 // defaultNodeHTTPClient reaches nodes trusting the system CA store ("verify"
 // defaultNodeHTTPClient reaches nodes trusting the system CA store ("verify"
 // mode or plain http); shared so connections pool across nodes.
 // mode or plain http); shared so connections pool across nodes.
 var defaultNodeHTTPClient = &http.Client{
 var defaultNodeHTTPClient = &http.Client{
@@ -62,6 +149,30 @@ func HTTPClientForNode(n *model.Node, proxyURL string) (*http.Client, error) {
 		mode = "verify"
 		mode = "verify"
 	}
 	}
 	if proxyURL != "" {
 	if proxyURL != "" {
+		if mode == "mtls" && n.Scheme != "http" {
+			timeout := remoteHTTPTimeout
+			build := func() (idleClosingRoundTripper, error) {
+				client, err := netproxy.NewHTTPClient(proxyURL, remoteHTTPTimeout)
+				if err != nil {
+					return nil, err
+				}
+				transport, ok := client.Transport.(*http.Transport)
+				if !ok {
+					return nil, common.NewError("mtls proxy client transport does not support credential rotation")
+				}
+				tlsCfg, err := tlsConfigForNode(n)
+				if err != nil {
+					return nil, err
+				}
+				transport.TLSClientConfig = tlsCfg
+				return transport, nil
+			}
+			transport, err := newCredentialRotatingTransport(build)
+			if err != nil {
+				return nil, err
+			}
+			return &http.Client{Transport: transport, Timeout: timeout}, nil
+		}
 		client, err := netproxy.NewHTTPClient(proxyURL, remoteHTTPTimeout)
 		client, err := netproxy.NewHTTPClient(proxyURL, remoteHTTPTimeout)
 		if err != nil {
 		if err != nil {
 			return nil, err
 			return nil, err
@@ -83,6 +194,26 @@ func HTTPClientForNode(n *model.Node, proxyURL string) (*http.Client, error) {
 	if mode == "verify" || n.Scheme == "http" {
 	if mode == "verify" || n.Scheme == "http" {
 		return defaultNodeHTTPClient, nil
 		return defaultNodeHTTPClient, nil
 	}
 	}
+	if mode == "mtls" {
+		build := func() (idleClosingRoundTripper, error) {
+			tlsCfg, err := tlsConfigForNode(n)
+			if err != nil {
+				return nil, err
+			}
+			return &http.Transport{
+				MaxIdleConns:        64,
+				MaxIdleConnsPerHost: 4,
+				IdleConnTimeout:     60 * time.Second,
+				DialContext:         netsafe.SSRFGuardedDialContext,
+				TLSClientConfig:     tlsCfg,
+			}, nil
+		}
+		transport, err := newCredentialRotatingTransport(build)
+		if err != nil {
+			return nil, err
+		}
+		return &http.Client{Transport: transport}, nil
+	}
 	tlsCfg, err := tlsConfigForNode(n)
 	tlsCfg, err := tlsConfigForNode(n)
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err

+ 233 - 0
internal/web/runtime/tls_client_test.go

@@ -11,12 +11,245 @@ import (
 	"net/url"
 	"net/url"
 	"strconv"
 	"strconv"
 	"strings"
 	"strings"
+	"sync"
+	"sync/atomic"
 	"testing"
 	"testing"
 
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
 )
 )
 
 
+type generationProbeTransport struct {
+	id     string
+	closed atomic.Int32
+}
+
+func (t *generationProbeTransport) RoundTrip(*http.Request) (*http.Response, error) {
+	return &http.Response{
+		StatusCode: http.StatusOK,
+		Body:       http.NoBody,
+		Header:     make(http.Header),
+		Request:    &http.Request{},
+	}, nil
+}
+
+func (t *generationProbeTransport) CloseIdleConnections() {
+	t.closed.Add(1)
+}
+
+func TestCredentialRotatingTransportDropsOldPoolBeforeNextRequest(t *testing.T) {
+	var selected atomic.Pointer[generationProbeTransport]
+	oldTransport := &generationProbeTransport{id: "old"}
+	newTransport := &generationProbeTransport{id: "new"}
+	selected.Store(oldTransport)
+
+	rotating, err := newCredentialRotatingTransport(func() (idleClosingRoundTripper, error) {
+		return selected.Load(), nil
+	})
+	if err != nil {
+		t.Fatalf("newCredentialRotatingTransport: %v", err)
+	}
+	rotating.mu.Lock()
+	initial := rotating.current
+	rotating.mu.Unlock()
+	if initial != oldTransport {
+		t.Fatalf("initial transport = %p, want old %p", initial, oldTransport)
+	}
+
+	selected.Store(newTransport)
+	InvalidateMasterClientConnections()
+
+	req := httptest.NewRequest(http.MethodGet, "https://node.example.test/panel/api/server/status", nil)
+	resp, err := rotating.RoundTrip(req)
+	if err != nil {
+		t.Fatalf("RoundTrip after credential rotation: %v", err)
+	}
+	_ = resp.Body.Close()
+
+	rotating.mu.Lock()
+	current := rotating.current
+	rotating.mu.Unlock()
+	if current != newTransport {
+		t.Fatalf("transport after invalidation = %p, want new %p", current, newTransport)
+	}
+	if got := oldTransport.closed.Load(); got != 1 {
+		t.Fatalf("old transport CloseIdleConnections calls = %d, want 1", got)
+	}
+}
+
+func TestReloadMasterClientConnectionsValidatesProviderBeforeInvalidation(t *testing.T) {
+	before := masterCertEpoch.Load()
+	SetMasterClientCertProvider(func() (tls.Certificate, error) {
+		return tls.Certificate{}, context.Canceled
+	})
+	if err := ReloadMasterClientConnections(); err == nil {
+		t.Fatal("reload with an invalid provider unexpectedly succeeded")
+	}
+	if got := masterCertEpoch.Load(); got != before {
+		t.Fatalf("failed reload changed generation from %d to %d", before, got)
+	}
+
+	SetMasterClientCertProvider(func() (tls.Certificate, error) {
+		return masterCertForTest(t), nil
+	})
+	t.Cleanup(func() { SetMasterClientCertProvider(nil) })
+	if err := ReloadMasterClientConnections(); err != nil {
+		t.Fatalf("ReloadMasterClientConnections: %v", err)
+	}
+	if got := masterCertEpoch.Load(); got != before+1 {
+		t.Fatalf("successful reload generation = %d, want %d", got, before+1)
+	}
+}
+
+func TestCredentialRotatingTransportRejectsBuildAcrossInvalidation(t *testing.T) {
+	oldTransport := &generationProbeTransport{id: "old"}
+	newTransport := &generationProbeTransport{id: "new"}
+	var selected atomic.Pointer[generationProbeTransport]
+	selected.Store(oldTransport)
+
+	firstBuildCaptured := make(chan struct{})
+	releaseFirstBuild := make(chan struct{})
+	var once sync.Once
+	build := func() (idleClosingRoundTripper, error) {
+		captured := selected.Load()
+		once.Do(func() {
+			close(firstBuildCaptured)
+			<-releaseFirstBuild
+		})
+		return captured, nil
+	}
+
+	type result struct {
+		transport *credentialRotatingTransport
+		err       error
+	}
+	resultCh := make(chan result, 1)
+	go func() {
+		transport, err := newCredentialRotatingTransport(build)
+		resultCh <- result{transport: transport, err: err}
+	}()
+
+	<-firstBuildCaptured
+	selected.Store(newTransport)
+	InvalidateMasterClientConnections()
+	close(releaseFirstBuild)
+
+	got := <-resultCh
+	if got.err != nil {
+		t.Fatalf("newCredentialRotatingTransport: %v", got.err)
+	}
+	got.transport.mu.Lock()
+	current := got.transport.current
+	got.transport.mu.Unlock()
+	if current != newTransport {
+		t.Fatalf("transport built across invalidation = %p, want new %p", current, newTransport)
+	}
+	if calls := oldTransport.closed.Load(); calls != 1 {
+		t.Fatalf("stale transport CloseIdleConnections calls = %d, want 1", calls)
+	}
+}
+
+func TestHTTPClientForNodeMTLSRebuildsTLSConfigAfterCredentialInvalidation(t *testing.T) {
+	oldCert := masterCertForTest(t)
+	newCert := masterCertForTest(t)
+	selected := oldCert
+	SetMasterClientCertProvider(func() (tls.Certificate, error) { return selected, nil })
+	t.Cleanup(func() { SetMasterClientCertProvider(nil) })
+
+	client, err := HTTPClientForNode(&model.Node{
+		Scheme:        "https",
+		Address:       "node.example.test",
+		Port:          443,
+		TlsVerifyMode: "mtls",
+	}, "")
+	if err != nil {
+		t.Fatalf("HTTPClientForNode: %v", err)
+	}
+	rotating, ok := client.Transport.(*credentialRotatingTransport)
+	if !ok {
+		t.Fatalf("transport = %T, want *credentialRotatingTransport", client.Transport)
+	}
+	leaf := func() []byte {
+		rotating.mu.Lock()
+		defer rotating.mu.Unlock()
+		transport, ok := rotating.current.(*http.Transport)
+		if !ok {
+			t.Fatalf("current transport = %T, want *http.Transport", rotating.current)
+		}
+		return transport.TLSClientConfig.Certificates[0].Certificate[0]
+	}
+	if got := leaf(); string(got) != string(oldCert.Certificate[0]) {
+		t.Fatal("initial TLS config does not contain the old credential")
+	}
+
+	selected = newCert
+	InvalidateMasterClientConnections()
+
+	ctx, cancel := context.WithCancel(context.Background())
+	cancel()
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://node.example.test/", nil)
+	if err != nil {
+		t.Fatalf("NewRequestWithContext: %v", err)
+	}
+	if _, err := client.Do(req); err == nil {
+		t.Fatal("canceled request unexpectedly succeeded")
+	}
+	if got := leaf(); string(got) != string(newCert.Certificate[0]) {
+		t.Fatal("TLS config retained the old credential after invalidation")
+	}
+}
+
+func TestHTTPClientForNodeProxyMTLSRebuildKeepsProxyAndNewCredential(t *testing.T) {
+	oldCert := masterCertForTest(t)
+	newCert := masterCertForTest(t)
+	selected := oldCert
+	SetMasterClientCertProvider(func() (tls.Certificate, error) { return selected, nil })
+	t.Cleanup(func() { SetMasterClientCertProvider(nil) })
+
+	const proxyURL = "http://127.0.0.1:18080"
+	client, err := HTTPClientForNode(&model.Node{Scheme: "https", TlsVerifyMode: "mtls"}, proxyURL)
+	if err != nil {
+		t.Fatalf("HTTPClientForNode: %v", err)
+	}
+	rotating, ok := client.Transport.(*credentialRotatingTransport)
+	if !ok {
+		t.Fatalf("transport = %T, want rotating transport", client.Transport)
+	}
+	current := func() *http.Transport {
+		rotating.mu.Lock()
+		defer rotating.mu.Unlock()
+		transport, ok := rotating.current.(*http.Transport)
+		if !ok {
+			t.Fatalf("current transport = %T, want *http.Transport", rotating.current)
+		}
+		return transport
+	}
+	assertProxy := func(transport *http.Transport) {
+		t.Helper()
+		if transport.Proxy == nil {
+			t.Fatalf("proxy function is nil, want %s", proxyURL)
+		}
+		req, _ := http.NewRequest(http.MethodGet, "https://node.example.test/", nil)
+		got, err := transport.Proxy(req)
+		if err != nil || got == nil || got.String() != proxyURL {
+			t.Fatalf("proxy = %v, error = %v, want %s", got, err, proxyURL)
+		}
+	}
+	assertProxy(current())
+
+	selected = newCert
+	InvalidateMasterClientConnections()
+	ctx, cancel := context.WithCancel(context.Background())
+	cancel()
+	req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://node.example.test/", nil)
+	_, _ = client.Do(req)
+	rebuilt := current()
+	assertProxy(rebuilt)
+	if got := rebuilt.TLSClientConfig.Certificates[0].Certificate[0]; string(got) != string(newCert.Certificate[0]) {
+		t.Fatal("proxy mTLS rebuild retained the old credential")
+	}
+}
+
 // masterCertForTest builds a real CA-signed client certificate for mtls tests.
 // masterCertForTest builds a real CA-signed client certificate for mtls tests.
 func masterCertForTest(t *testing.T) tls.Certificate {
 func masterCertForTest(t *testing.T) tls.Certificate {
 	t.Helper()
 	t.Helper()

+ 162 - 0
internal/web/runtime/tls_client_wire_test.go

@@ -0,0 +1,162 @@
+package runtime
+
+import (
+	"crypto/sha256"
+	"crypto/tls"
+	"crypto/x509"
+	"encoding/hex"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"sync"
+	"testing"
+	"time"
+)
+
+type wireObservation struct {
+	pin        string
+	remoteAddr string
+}
+
+func startLeafRecordingServer(t *testing.T) (*httptest.Server, *x509.CertPool, func() []wireObservation) {
+	t.Helper()
+	var mu sync.Mutex
+	var seen []wireObservation
+	srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		observation := wireObservation{remoteAddr: r.RemoteAddr}
+		if r.TLS != nil && len(r.TLS.PeerCertificates) > 0 {
+			sum := sha256.Sum256(r.TLS.PeerCertificates[0].Raw)
+			observation.pin = hex.EncodeToString(sum[:])
+		}
+		mu.Lock()
+		seen = append(seen, observation)
+		mu.Unlock()
+		w.WriteHeader(http.StatusOK)
+		_, _ = w.Write([]byte("ok"))
+	}))
+	srv.TLS = &tls.Config{ClientAuth: tls.RequestClientCert}
+	srv.StartTLS()
+	t.Cleanup(srv.Close)
+	pool := x509.NewCertPool()
+	pool.AddCert(srv.Certificate())
+	return srv, pool, func() []wireObservation {
+		mu.Lock()
+		defer mu.Unlock()
+		result := make([]wireObservation, len(seen))
+		copy(result, seen)
+		return result
+	}
+}
+
+func pinOf(t *testing.T, cert tls.Certificate) string {
+	t.Helper()
+	sum := sha256.Sum256(cert.Certificate[0])
+	return hex.EncodeToString(sum[:])
+}
+
+func rotatingClientForTest(t *testing.T, roots *x509.CertPool) *http.Client {
+	t.Helper()
+	build := func() (idleClosingRoundTripper, error) {
+		cert, err := getMasterClientCert()
+		if err != nil {
+			return nil, err
+		}
+		return &http.Transport{
+			MaxIdleConns:        64,
+			MaxIdleConnsPerHost: 4,
+			IdleConnTimeout:     60 * time.Second,
+			TLSClientConfig: &tls.Config{
+				Certificates: []tls.Certificate{cert},
+				RootCAs:      roots,
+				MinVersion:   tls.VersionTLS12,
+			},
+		}, nil
+	}
+	transport, err := newCredentialRotatingTransport(build)
+	if err != nil {
+		t.Fatalf("newCredentialRotatingTransport: %v", err)
+	}
+	return &http.Client{Transport: transport, Timeout: 10 * time.Second}
+}
+
+func doWireRequest(t *testing.T, client *http.Client, url string) {
+	t.Helper()
+	response, err := client.Get(url)
+	if err != nil {
+		t.Fatalf("request: %v", err)
+	}
+	_, _ = io.Copy(io.Discard, response.Body)
+	_ = response.Body.Close()
+	if response.StatusCode != http.StatusOK {
+		t.Fatalf("status=%d want=%d", response.StatusCode, http.StatusOK)
+	}
+}
+
+func TestCredentialRotationPresentsNewLeafOnNextConnection(t *testing.T) {
+	server, roots, observations := startLeafRecordingServer(t)
+	oldCert := masterCertForTest(t)
+	newCert := masterCertForTest(t)
+	oldPin := pinOf(t, oldCert)
+	newPin := pinOf(t, newCert)
+	if oldPin == newPin {
+		t.Fatal("test fixture produced identical leaves")
+	}
+	var providerMu sync.Mutex
+	current := oldCert
+	SetMasterClientCertProvider(func() (tls.Certificate, error) {
+		providerMu.Lock()
+		defer providerMu.Unlock()
+		return current, nil
+	})
+	t.Cleanup(func() { SetMasterClientCertProvider(nil) })
+	client := rotatingClientForTest(t, roots)
+	doWireRequest(t, client, server.URL)
+	doWireRequest(t, client, server.URL)
+	baseline := observations()
+	if len(baseline) != 2 || baseline[0].pin != oldPin || baseline[1].pin != oldPin {
+		t.Fatalf("baseline=%v", baseline)
+	}
+	if baseline[0].remoteAddr != baseline[1].remoteAddr {
+		t.Fatalf("baseline connections differ: %v", baseline)
+	}
+	providerMu.Lock()
+	current = newCert
+	providerMu.Unlock()
+	InvalidateMasterClientConnections()
+	doWireRequest(t, client, server.URL)
+	after := observations()
+	if len(after) != 3 || after[2].pin != newPin {
+		t.Fatalf("rotation observations=%v want new leaf=%s", after, newPin)
+	}
+	if after[2].remoteAddr == baseline[1].remoteAddr {
+		t.Fatalf("rotated request reused stale connection %s", after[2].remoteAddr)
+	}
+}
+
+func TestCredentialRotationControlKeepsOldLeafWithoutInvalidation(t *testing.T) {
+	server, roots, observations := startLeafRecordingServer(t)
+	oldCert := masterCertForTest(t)
+	newCert := masterCertForTest(t)
+	oldPin := pinOf(t, oldCert)
+	var providerMu sync.Mutex
+	current := oldCert
+	SetMasterClientCertProvider(func() (tls.Certificate, error) {
+		providerMu.Lock()
+		defer providerMu.Unlock()
+		return current, nil
+	})
+	t.Cleanup(func() { SetMasterClientCertProvider(nil) })
+	client := rotatingClientForTest(t, roots)
+	doWireRequest(t, client, server.URL)
+	providerMu.Lock()
+	current = newCert
+	providerMu.Unlock()
+	doWireRequest(t, client, server.URL)
+	got := observations()
+	if len(got) != 2 || got[1].pin != oldPin {
+		t.Fatalf("control observations=%v want stale leaf=%s", got, oldPin)
+	}
+	if got[0].remoteAddr != got[1].remoteAddr {
+		t.Fatalf("control did not reuse connection: %v", got)
+	}
+}

+ 18 - 0
internal/web/service/node_mtls.go

@@ -1,11 +1,13 @@
 package service
 package service
 
 
 import (
 import (
+	"crypto/tls"
 	"crypto/x509"
 	"crypto/x509"
 	"encoding/pem"
 	"encoding/pem"
 	"strings"
 	"strings"
 
 
 	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
 )
 )
 
 
 // NodeMtlsCaCert returns the PEM of this panel's node-auth CA certificate (the
 // NodeMtlsCaCert returns the PEM of this panel's node-auth CA certificate (the
@@ -24,6 +26,22 @@ func (s *NodeService) NodeMtlsCaCert() (string, error) {
 	return string(ca.CertPEM), nil
 	return string(ca.CertPEM), nil
 }
 }
 
 
+// ReloadMasterMtlsClient validates the master credential currently stored by
+// the panel and drops cached mTLS connection pools. This makes an intentional
+// out-of-process credential rotation take effect without restarting x-ui (and
+// therefore without stopping the xray child process in the same service).
+func (s *NodeService) ReloadMasterMtlsClient() error {
+	stored, err := (&SettingService{}).LoadMasterClientCert()
+	if err != nil {
+		return err
+	}
+	if _, err := tls.X509KeyPair(stored.CertPEM, stored.KeyPEM); err != nil {
+		return err
+	}
+	runtime.InvalidateMasterClientConnections()
+	return nil
+}
+
 // SetNodeMtlsTrustCA stores the CA certificate this panel trusts for incoming
 // SetNodeMtlsTrustCA stores the CA certificate this panel trusts for incoming
 // node-API client certificates. An empty value clears it (mTLS off). A
 // node-API client certificates. An empty value clears it (mTLS off). A
 // non-empty value must be a PEM certificate (fail closed). Takes effect on the
 // non-empty value must be a PEM certificate (fail closed). Takes effect on the

+ 26 - 0
internal/web/service/node_mtls_test.go

@@ -1,15 +1,41 @@
 package service
 package service
 
 
 import (
 import (
+	"crypto/tls"
 	"crypto/x509"
 	"crypto/x509"
 	"encoding/pem"
 	"encoding/pem"
 	"testing"
 	"testing"
 
 
 	"github.com/go-playground/validator/v10"
 	"github.com/go-playground/validator/v10"
 
 
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
 )
 )
 
 
+func TestReloadMasterMtlsClientDoesNotMintMissingCredential(t *testing.T) {
+	_ = setupSettingMtlsDB(t)
+	runtime.SetMasterClientCertProvider(func() (tls.Certificate, error) {
+		pair, err := (&SettingService{}).EnsureMasterClientCert()
+		if err != nil {
+			return tls.Certificate{}, err
+		}
+		return tls.X509KeyPair(pair.CertPEM, pair.KeyPEM)
+	})
+	t.Cleanup(func() { runtime.SetMasterClientCertProvider(nil) })
+	if err := (&NodeService{}).ReloadMasterMtlsClient(); err == nil {
+		t.Fatal("reload on a fresh database unexpectedly succeeded")
+	}
+	var count int64
+	keys := []string{settingNodeMtlsCaCert, settingNodeMtlsCaKey, settingNodeMtlsClientCert, settingNodeMtlsClientKey}
+	if err := database.GetDB().Model(&model.Setting{}).Where("key IN ?", keys).Count(&count).Error; err != nil {
+		t.Fatalf("count mTLS settings: %v", err)
+	}
+	if count != 0 {
+		t.Fatalf("reload created %d mTLS setting rows, want 0", count)
+	}
+}
+
 func TestNormalizeKeepsMtls(t *testing.T) {
 func TestNormalizeKeepsMtls(t *testing.T) {
 	s := &NodeService{}
 	s := &NodeService{}
 	cases := []struct {
 	cases := []struct {

+ 19 - 0
internal/web/service/setting_mtls.go

@@ -15,6 +15,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
 )
 )
 
 
 var masterClientCredentialMu sync.Mutex
 var masterClientCredentialMu sync.Mutex
@@ -133,6 +134,7 @@ func (s *SettingService) EnsureMasterClientCert() (crypto.CertKeyPEM, error) {
 	if err := saveMasterClientCredential(client, pin); err != nil {
 	if err := saveMasterClientCredential(client, pin); err != nil {
 		return crypto.CertKeyPEM{}, err
 		return crypto.CertKeyPEM{}, err
 	}
 	}
+	runtime.InvalidateMasterClientConnections()
 	return client, nil
 	return client, nil
 }
 }
 
 
@@ -158,6 +160,23 @@ func saveMasterClientCredential(client crypto.CertKeyPEM, pin string) error {
 	})
 	})
 }
 }
 
 
+// LoadMasterClientCert returns only the already-persisted credential. It never
+// mints or changes CA/client settings.
+func (s *SettingService) LoadMasterClientCert() (crypto.CertKeyPEM, error) {
+	certPem, err := s.getString(settingNodeMtlsClientCert)
+	if err != nil {
+		return crypto.CertKeyPEM{}, err
+	}
+	keyPem, err := s.getString(settingNodeMtlsClientKey)
+	if err != nil {
+		return crypto.CertKeyPEM{}, err
+	}
+	if certPem == "" || keyPem == "" {
+		return crypto.CertKeyPEM{}, common.NewError("master client certificate is not fully configured")
+	}
+	return crypto.CertKeyPEM{CertPEM: []byte(certPem), KeyPEM: []byte(keyPem)}, nil
+}
+
 // NodeMtlsClientCAPool builds the trust pool used as the panel listener's
 // NodeMtlsClientCAPool builds the trust pool used as the panel listener's
 // ClientCAs for incoming node-API client certificates. It returns (nil, nil)
 // ClientCAs for incoming node-API client certificates. It returns (nil, nil)
 // when no trust CA is configured, so mTLS stays off and the listener behaves
 // when no trust CA is configured, so mTLS stays off and the listener behaves

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

@@ -974,7 +974,8 @@
         "updateStarted": "بدأ تحديث اللوحة",
         "updateStarted": "بدأ تحديث اللوحة",
         "updateResult": "تم بدء التحديث على {ok} عقدة، فشل {failed}",
         "updateResult": "تم بدء التحديث على {ok} عقدة، فشل {failed}",
         "updateNoneEligible": "اختر عقدة واحدة على الأقل متصلة ومفعّلة",
         "updateNoneEligible": "اختر عقدة واحدة على الأقل متصلة ومفعّلة",
-        "saveMtls": "حفظ mTLS النود"
+        "saveMtls": "حفظ mTLS النود",
+        "reloadMtls": "Reload master mTLS credential"
       },
       },
       "tlsVerifyMode": "التحقق من TLS",
       "tlsVerifyMode": "التحقق من TLS",
       "tlsVerifyModeHint": "كيف يتحقق اللوحة من شهادة HTTPS الخاصة بالعقدة. التثبيت أو التخطّي مخصّصان للشهادات الموقّعة ذاتيًا (عُقد https فقط).",
       "tlsVerifyModeHint": "كيف يتحقق اللوحة من شهادة HTTPS الخاصة بالعقدة. التثبيت أو التخطّي مخصّصان للشهادات الموقّعة ذاتيًا (عُقد https فقط).",

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

@@ -1091,7 +1091,8 @@
         "updateStarted": "Panel update started",
         "updateStarted": "Panel update started",
         "updateResult": "Update triggered on {ok} node(s), {failed} failed",
         "updateResult": "Update triggered on {ok} node(s), {failed} failed",
         "updateNoneEligible": "Select at least one online, enabled node",
         "updateNoneEligible": "Select at least one online, enabled node",
-        "saveMtls": "Save node mTLS"
+        "saveMtls": "Save node mTLS",
+        "reloadMtls": "Reload master mTLS credential"
       },
       },
       "tlsVerifyMode": "TLS verification",
       "tlsVerifyMode": "TLS verification",
       "tlsVerifyModeHint": "How the panel validates the node's HTTPS certificate. Pin or Skip are for self-signed certs (https nodes only).",
       "tlsVerifyModeHint": "How the panel validates the node's HTTPS certificate. Pin or Skip are for self-signed certs (https nodes only).",

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

@@ -974,7 +974,8 @@
         "updateStarted": "Actualización del panel iniciada",
         "updateStarted": "Actualización del panel iniciada",
         "updateResult": "Actualización iniciada en {ok} nodo(s), {failed} fallaron",
         "updateResult": "Actualización iniciada en {ok} nodo(s), {failed} fallaron",
         "updateNoneEligible": "Selecciona al menos un nodo en línea y habilitado",
         "updateNoneEligible": "Selecciona al menos un nodo en línea y habilitado",
-        "saveMtls": "Guardar mTLS del nodo"
+        "saveMtls": "Guardar mTLS del nodo",
+        "reloadMtls": "Reload master mTLS credential"
       },
       },
       "tlsVerifyMode": "Verificación TLS",
       "tlsVerifyMode": "Verificación TLS",
       "tlsVerifyModeHint": "Cómo valida el panel el certificado HTTPS del nodo. Fijar u Omitir son para certificados autofirmados (solo nodos https).",
       "tlsVerifyModeHint": "Cómo valida el panel el certificado HTTPS del nodo. Fijar u Omitir son para certificados autofirmados (solo nodos https).",

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

@@ -974,7 +974,8 @@
         "updateStarted": "به‌روزرسانی پنل آغاز شد",
         "updateStarted": "به‌روزرسانی پنل آغاز شد",
         "updateResult": "به‌روزرسانی روی {ok} نود آغاز شد، {failed} ناموفق",
         "updateResult": "به‌روزرسانی روی {ok} نود آغاز شد، {failed} ناموفق",
         "updateNoneEligible": "حداقل یک نود آنلاین و فعال انتخاب کنید",
         "updateNoneEligible": "حداقل یک نود آنلاین و فعال انتخاب کنید",
-        "saveMtls": "ذخیره mTLS نود"
+        "saveMtls": "ذخیره mTLS نود",
+        "reloadMtls": "Reload master mTLS credential"
       },
       },
       "tlsVerifyMode": "اعتبارسنجی TLS",
       "tlsVerifyMode": "اعتبارسنجی TLS",
       "tlsVerifyModeHint": "اینکه پنل گواهی HTTPS نود را چطور بررسی کند. Pin یا Skip برای گواهی‌های self-signed است (فقط نودهای https).",
       "tlsVerifyModeHint": "اینکه پنل گواهی HTTPS نود را چطور بررسی کند. Pin یا Skip برای گواهی‌های self-signed است (فقط نودهای https).",

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

@@ -974,7 +974,8 @@
         "updateStarted": "Pembaruan panel dimulai",
         "updateStarted": "Pembaruan panel dimulai",
         "updateResult": "Pembaruan dipicu pada {ok} node, {failed} gagal",
         "updateResult": "Pembaruan dipicu pada {ok} node, {failed} gagal",
         "updateNoneEligible": "Pilih minimal satu node online dan aktif",
         "updateNoneEligible": "Pilih minimal satu node online dan aktif",
-        "saveMtls": "Simpan mTLS node"
+        "saveMtls": "Simpan mTLS node",
+        "reloadMtls": "Reload master mTLS credential"
       },
       },
       "tlsVerifyMode": "Verifikasi TLS",
       "tlsVerifyMode": "Verifikasi TLS",
       "tlsVerifyModeHint": "Cara panel memvalidasi sertifikat HTTPS node. Pin atau Lewati untuk sertifikat self-signed (hanya node https).",
       "tlsVerifyModeHint": "Cara panel memvalidasi sertifikat HTTPS node. Pin atau Lewati untuk sertifikat self-signed (hanya node https).",

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

@@ -974,7 +974,8 @@
         "updateStarted": "パネルの更新を開始しました",
         "updateStarted": "パネルの更新を開始しました",
         "updateResult": "{ok} 個のノードで更新を開始、{failed} 個失敗",
         "updateResult": "{ok} 個のノードで更新を開始、{failed} 個失敗",
         "updateNoneEligible": "オンラインで有効なノードを少なくとも1つ選択してください",
         "updateNoneEligible": "オンラインで有効なノードを少なくとも1つ選択してください",
-        "saveMtls": "ノード mTLS を保存"
+        "saveMtls": "ノード mTLS を保存",
+        "reloadMtls": "Reload master mTLS credential"
       },
       },
       "tlsVerifyMode": "TLS 検証",
       "tlsVerifyMode": "TLS 検証",
       "tlsVerifyModeHint": "パネルがノードの HTTPS 証明書を検証する方法。ピン留めやスキップは自己署名証明書向け(https ノードのみ)。",
       "tlsVerifyModeHint": "パネルがノードの HTTPS 証明書を検証する方法。ピン留めやスキップは自己署名証明書向け(https ノードのみ)。",

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

@@ -974,7 +974,8 @@
         "updateStarted": "Atualização do painel iniciada",
         "updateStarted": "Atualização do painel iniciada",
         "updateResult": "Atualização iniciada em {ok} nó(s), {failed} falharam",
         "updateResult": "Atualização iniciada em {ok} nó(s), {failed} falharam",
         "updateNoneEligible": "Selecione pelo menos um nó online e ativo",
         "updateNoneEligible": "Selecione pelo menos um nó online e ativo",
-        "saveMtls": "Salvar mTLS do nó"
+        "saveMtls": "Salvar mTLS do nó",
+        "reloadMtls": "Reload master mTLS credential"
       },
       },
       "tlsVerifyMode": "Verificação TLS",
       "tlsVerifyMode": "Verificação TLS",
       "tlsVerifyModeHint": "Como o painel valida o certificado HTTPS do nó. Fixar ou Ignorar são para certificados autoassinados (apenas nós https).",
       "tlsVerifyModeHint": "Como o painel valida o certificado HTTPS do nó. Fixar ou Ignorar são para certificados autoassinados (apenas nós https).",

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

@@ -974,7 +974,8 @@
         "updateStarted": "Обновление панели запущено",
         "updateStarted": "Обновление панели запущено",
         "updateResult": "Обновление запущено на {ok} узлах, {failed} не удалось",
         "updateResult": "Обновление запущено на {ok} узлах, {failed} не удалось",
         "updateNoneEligible": "Выберите хотя бы один включённый узел в сети",
         "updateNoneEligible": "Выберите хотя бы один включённый узел в сети",
-        "saveMtls": "Сохранить mTLS узла"
+        "saveMtls": "Сохранить mTLS узла",
+        "reloadMtls": "Reload master mTLS credential"
       },
       },
       "tlsVerifyMode": "Проверка TLS",
       "tlsVerifyMode": "Проверка TLS",
       "tlsVerifyModeHint": "Как панель проверяет HTTPS-сертификат узла. Закрепление или Пропуск — для самоподписанных сертификатов (только https-узлы).",
       "tlsVerifyModeHint": "Как панель проверяет HTTPS-сертификат узла. Закрепление или Пропуск — для самоподписанных сертификатов (только https-узлы).",

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

@@ -974,7 +974,8 @@
         "updateStarted": "Panel güncellemesi başlatıldı",
         "updateStarted": "Panel güncellemesi başlatıldı",
         "updateResult": "{ok} düğümde güncelleme başlatıldı, {failed} başarısız",
         "updateResult": "{ok} düğümde güncelleme başlatıldı, {failed} başarısız",
         "updateNoneEligible": "En az bir çevrimiçi ve etkin düğüm seçin",
         "updateNoneEligible": "En az bir çevrimiçi ve etkin düğüm seçin",
-        "saveMtls": "Düğüm mTLS kaydet"
+        "saveMtls": "Düğüm mTLS kaydet",
+        "reloadMtls": "Reload master mTLS credential"
       },
       },
       "tlsVerifyMode": "TLS Doğrulaması",
       "tlsVerifyMode": "TLS Doğrulaması",
       "tlsVerifyModeHint": "Panelin düğümün HTTPS sertifikasını nasıl doğrulayacağını belirler. Sabitle veya Atla, kendinden imzalı sertifikalar içindir (yalnızca https düğümleri).",
       "tlsVerifyModeHint": "Panelin düğümün HTTPS sertifikasını nasıl doğrulayacağını belirler. Sabitle veya Atla, kendinden imzalı sertifikalar içindir (yalnızca https düğümleri).",

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

@@ -974,7 +974,8 @@
         "updateStarted": "Оновлення панелі розпочато",
         "updateStarted": "Оновлення панелі розпочато",
         "updateResult": "Оновлення запущено на {ok} вузлах, {failed} не вдалося",
         "updateResult": "Оновлення запущено на {ok} вузлах, {failed} не вдалося",
         "updateNoneEligible": "Виберіть принаймні один увімкнений вузол у мережі",
         "updateNoneEligible": "Виберіть принаймні один увімкнений вузол у мережі",
-        "saveMtls": "Зберегти mTLS вузла"
+        "saveMtls": "Зберегти mTLS вузла",
+        "reloadMtls": "Reload master mTLS credential"
       },
       },
       "tlsVerifyMode": "Перевірка TLS",
       "tlsVerifyMode": "Перевірка TLS",
       "tlsVerifyModeHint": "Як панель перевіряє HTTPS-сертифікат вузла. Закріплення або Пропуск — для самопідписаних сертифікатів (лише https-вузли).",
       "tlsVerifyModeHint": "Як панель перевіряє HTTPS-сертифікат вузла. Закріплення або Пропуск — для самопідписаних сертифікатів (лише https-вузли).",

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

@@ -974,7 +974,8 @@
         "updateStarted": "Đã bắt đầu cập nhật bảng điều khiển",
         "updateStarted": "Đã bắt đầu cập nhật bảng điều khiển",
         "updateResult": "Đã kích hoạt cập nhật trên {ok} node, {failed} thất bại",
         "updateResult": "Đã kích hoạt cập nhật trên {ok} node, {failed} thất bại",
         "updateNoneEligible": "Chọn ít nhất một node trực tuyến và đang bật",
         "updateNoneEligible": "Chọn ít nhất một node trực tuyến và đang bật",
-        "saveMtls": "Lưu mTLS nút"
+        "saveMtls": "Lưu mTLS nút",
+        "reloadMtls": "Reload master mTLS credential"
       },
       },
       "tlsVerifyMode": "Xác minh TLS",
       "tlsVerifyMode": "Xác minh TLS",
       "tlsVerifyModeHint": "Cách panel xác thực chứng chỉ HTTPS của node. Ghim hoặc Bỏ qua dành cho chứng chỉ tự ký (chỉ node https).",
       "tlsVerifyModeHint": "Cách panel xác thực chứng chỉ HTTPS của node. Ghim hoặc Bỏ qua dành cho chứng chỉ tự ký (chỉ node https).",

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

@@ -974,7 +974,8 @@
         "updateStarted": "已开始更新面板",
         "updateStarted": "已开始更新面板",
         "updateResult": "已在 {ok} 个节点上触发更新,{failed} 个失败",
         "updateResult": "已在 {ok} 个节点上触发更新,{failed} 个失败",
         "updateNoneEligible": "请至少选择一个在线且已启用的节点",
         "updateNoneEligible": "请至少选择一个在线且已启用的节点",
-        "saveMtls": "保存节点 mTLS"
+        "saveMtls": "保存节点 mTLS",
+        "reloadMtls": "Reload master mTLS credential"
       },
       },
       "tlsVerifyMode": "TLS 校验",
       "tlsVerifyMode": "TLS 校验",
       "tlsVerifyModeHint": "面板如何校验节点的 HTTPS 证书。固定或跳过用于自签名证书(仅 https 节点)。",
       "tlsVerifyModeHint": "面板如何校验节点的 HTTPS 证书。固定或跳过用于自签名证书(仅 https 节点)。",

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

@@ -974,7 +974,8 @@
         "updateStarted": "已開始更新面板",
         "updateStarted": "已開始更新面板",
         "updateResult": "已在 {ok} 個節點上觸發更新,{failed} 個失敗",
         "updateResult": "已在 {ok} 個節點上觸發更新,{failed} 個失敗",
         "updateNoneEligible": "請至少選擇一個在線且已啟用的節點",
         "updateNoneEligible": "請至少選擇一個在線且已啟用的節點",
-        "saveMtls": "儲存節點 mTLS"
+        "saveMtls": "儲存節點 mTLS",
+        "reloadMtls": "Reload master mTLS credential"
       },
       },
       "tlsVerifyMode": "TLS 驗證",
       "tlsVerifyMode": "TLS 驗證",
       "tlsVerifyModeHint": "面板如何驗證節點的 HTTPS 憑證。釘選或略過用於自簽憑證(僅 https 節點)。",
       "tlsVerifyModeHint": "面板如何驗證節點的 HTTPS 憑證。釘選或略過用於自簽憑證(僅 https 節點)。",