Преглед на файлове

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 преди 5 часа
родител
ревизия
7ecd88b9e3

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

@@ -1,51 +1,40 @@
 ---
 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
 _openapi:
   preload:
     - ./public/openapi.json
   toc:
     - 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.
-      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
-      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
         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
-      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
         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.
-      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
       title: Fetch a single node by ID.
       url: '#fetch-a-single-node-by-id'
     - 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-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
+      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
-      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.
-      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
       title: Replace a node’s 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.
       url: '#pause-or-resume-traffic-sync-with-this-node'
     - 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.
-      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
-      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
         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
+      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
-      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
       title: Probe an existing node, updating its cached health state.
       url: '#probe-an-existing-node-updating-its-cached-health-state'
     - 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
         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
-        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
-      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.
-      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:
     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.
-        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
           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
           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.
-        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.
         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-assigned inbound gets paths that exist on the node, not the
           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.
-        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.
         id: replace-a-nodes-connection-details-same-body-shape-as-add
       - content: 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.
         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
           Node dialog to fetch and pin a self-signed certificate. Uses the same
           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.
-        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.
         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
           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 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.
-        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: []
 ---
 
@@ -177,7 +145,7 @@ export default function Layout(props) {
   return (
     <>
       {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}": {
       "get": {
         "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.",
         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',
         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.POST("/mtls/ca", a.mtlsCa)
 	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

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

@@ -9,6 +9,7 @@ import (
 	"net/http"
 	"strings"
 	"sync"
+	"sync/atomic"
 	"time"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
@@ -25,6 +26,7 @@ type MasterClientCertProvider func() (tls.Certificate, error)
 var (
 	masterClientCertMu sync.RWMutex
 	masterClientCert   MasterClientCertProvider
+	masterCertEpoch    atomic.Uint64
 )
 
 // SetMasterClientCertProvider installs the provider used to obtain the master
@@ -45,6 +47,91 @@ func getMasterClientCert() (tls.Certificate, error) {
 	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"
 // mode or plain http); shared so connections pool across nodes.
 var defaultNodeHTTPClient = &http.Client{
@@ -62,6 +149,30 @@ func HTTPClientForNode(n *model.Node, proxyURL string) (*http.Client, error) {
 		mode = "verify"
 	}
 	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)
 		if err != nil {
 			return nil, err
@@ -83,6 +194,26 @@ func HTTPClientForNode(n *model.Node, proxyURL string) (*http.Client, error) {
 	if mode == "verify" || n.Scheme == "http" {
 		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)
 	if err != nil {
 		return nil, err

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

@@ -11,12 +11,245 @@ import (
 	"net/url"
 	"strconv"
 	"strings"
+	"sync"
+	"sync/atomic"
 	"testing"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"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.
 func masterCertForTest(t *testing.T) tls.Certificate {
 	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
 
 import (
+	"crypto/tls"
 	"crypto/x509"
 	"encoding/pem"
 	"strings"
 
 	"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
@@ -24,6 +26,22 @@ func (s *NodeService) NodeMtlsCaCert() (string, error) {
 	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
 // 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

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

@@ -1,15 +1,41 @@
 package service
 
 import (
+	"crypto/tls"
 	"crypto/x509"
 	"encoding/pem"
 	"testing"
 
 	"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/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) {
 	s := &NodeService{}
 	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/util/common"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
 )
 
 var masterClientCredentialMu sync.Mutex
@@ -133,6 +134,7 @@ func (s *SettingService) EnsureMasterClientCert() (crypto.CertKeyPEM, error) {
 	if err := saveMasterClientCredential(client, pin); err != nil {
 		return crypto.CertKeyPEM{}, err
 	}
+	runtime.InvalidateMasterClientConnections()
 	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
 // 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

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

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

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

@@ -1091,7 +1091,8 @@
         "updateStarted": "Panel update started",
         "updateResult": "Update triggered on {ok} node(s), {failed} failed",
         "updateNoneEligible": "Select at least one online, enabled node",
-        "saveMtls": "Save node mTLS"
+        "saveMtls": "Save node mTLS",
+        "reloadMtls": "Reload master mTLS credential"
       },
       "tlsVerifyMode": "TLS verification",
       "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",
         "updateResult": "Actualización iniciada en {ok} nodo(s), {failed} fallaron",
         "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",
       "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": "به‌روزرسانی پنل آغاز شد",
         "updateResult": "به‌روزرسانی روی {ok} نود آغاز شد، {failed} ناموفق",
         "updateNoneEligible": "حداقل یک نود آنلاین و فعال انتخاب کنید",
-        "saveMtls": "ذخیره mTLS نود"
+        "saveMtls": "ذخیره mTLS نود",
+        "reloadMtls": "Reload master mTLS credential"
       },
       "tlsVerifyMode": "اعتبارسنجی TLS",
       "tlsVerifyModeHint": "اینکه پنل گواهی HTTPS نود را چطور بررسی کند. Pin یا Skip برای گواهی‌های self-signed است (فقط نودهای https).",

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

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

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

@@ -974,7 +974,8 @@
         "updateStarted": "Atualização do painel iniciada",
         "updateResult": "Atualização iniciada em {ok} nó(s), {failed} falharam",
         "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",
       "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": "Обновление панели запущено",
         "updateResult": "Обновление запущено на {ok} узлах, {failed} не удалось",
         "updateNoneEligible": "Выберите хотя бы один включённый узел в сети",
-        "saveMtls": "Сохранить mTLS узла"
+        "saveMtls": "Сохранить mTLS узла",
+        "reloadMtls": "Reload master mTLS credential"
       },
       "tlsVerifyMode": "Проверка TLS",
       "tlsVerifyModeHint": "Как панель проверяет HTTPS-сертификат узла. Закрепление или Пропуск — для самоподписанных сертификатов (только https-узлы).",

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

@@ -974,7 +974,8 @@
         "updateStarted": "Panel güncellemesi başlatıldı",
         "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",
-        "saveMtls": "Düğüm mTLS kaydet"
+        "saveMtls": "Düğüm mTLS kaydet",
+        "reloadMtls": "Reload master mTLS credential"
       },
       "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).",

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

@@ -974,7 +974,8 @@
         "updateStarted": "Оновлення панелі розпочато",
         "updateResult": "Оновлення запущено на {ok} вузлах, {failed} не вдалося",
         "updateNoneEligible": "Виберіть принаймні один увімкнений вузол у мережі",
-        "saveMtls": "Зберегти mTLS вузла"
+        "saveMtls": "Зберегти mTLS вузла",
+        "reloadMtls": "Reload master mTLS credential"
       },
       "tlsVerifyMode": "Перевірка TLS",
       "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",
         "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",
-        "saveMtls": "Lưu mTLS nút"
+        "saveMtls": "Lưu mTLS nút",
+        "reloadMtls": "Reload master mTLS credential"
       },
       "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).",

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

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

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

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