diff --git a/docs/content/docs/en/reference/api/nodes.mdx b/docs/content/docs/en/reference/api/nodes.mdx
index 569baa7ff..1059c0ea5 100644
--- a/docs/content/docs/en/reference/api/nodes.mdx
+++ b/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}
-
+
>
);
}
\ No newline at end of file
diff --git a/docs/public/openapi.json b/docs/public/openapi.json
index caddb57de..d82f6de1d 100644
--- a/docs/public/openapi.json
+++ b/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": {}
+ }
+ }
+ }
+ }
+ }
+ }
+ }
}
}
}
diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json
index 5797c4c83..3d343177d 100644
--- a/frontend/public/openapi.json
+++ b/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": [
diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts
index 37490f4d2..88cd911cf 100644
--- a/frontend/src/pages/api-docs/endpoints.ts
+++ b/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',
diff --git a/internal/web/controller/node.go b/internal/web/controller/node.go
index f9ac74c2c..beaaec68c 100644
--- a/internal/web/controller/node.go
+++ b/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
diff --git a/internal/web/runtime/tls_client.go b/internal/web/runtime/tls_client.go
index 6fb770097..0e3276979 100644
--- a/internal/web/runtime/tls_client.go
+++ b/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
diff --git a/internal/web/runtime/tls_client_test.go b/internal/web/runtime/tls_client_test.go
index ec06a0c35..9059e9f45 100644
--- a/internal/web/runtime/tls_client_test.go
+++ b/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()
diff --git a/internal/web/runtime/tls_client_wire_test.go b/internal/web/runtime/tls_client_wire_test.go
new file mode 100644
index 000000000..7b2c90b14
--- /dev/null
+++ b/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)
+ }
+}
diff --git a/internal/web/service/node_mtls.go b/internal/web/service/node_mtls.go
index 1be3785a6..18bb1f77b 100644
--- a/internal/web/service/node_mtls.go
+++ b/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
diff --git a/internal/web/service/node_mtls_test.go b/internal/web/service/node_mtls_test.go
index 704db997b..4571fb6fa 100644
--- a/internal/web/service/node_mtls_test.go
+++ b/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 {
diff --git a/internal/web/service/setting_mtls.go b/internal/web/service/setting_mtls.go
index 65bd01ede..e6c9abb48 100644
--- a/internal/web/service/setting_mtls.go
+++ b/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
diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json
index 5843ff974..6112f5c00 100644
--- a/internal/web/translation/ar-EG.json
+++ b/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 فقط).",
diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json
index 30cc60a6a..de385ae79 100644
--- a/internal/web/translation/en-US.json
+++ b/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).",
diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json
index a5df087fd..58eef72a1 100644
--- a/internal/web/translation/es-ES.json
+++ b/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).",
diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json
index 553c51283..6feb05bff 100644
--- a/internal/web/translation/fa-IR.json
+++ b/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).",
diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json
index 5d440ce8c..bdd833cda 100644
--- a/internal/web/translation/id-ID.json
+++ b/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).",
diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json
index 8f6324c6d..da86bc74d 100644
--- a/internal/web/translation/ja-JP.json
+++ b/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 ノードのみ)。",
diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json
index 179312834..cc684eb3b 100644
--- a/internal/web/translation/pt-BR.json
+++ b/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).",
diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json
index f88b2aa77..bf5b80a30 100644
--- a/internal/web/translation/ru-RU.json
+++ b/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-узлы).",
diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json
index a4c03ae01..2592bbb77 100644
--- a/internal/web/translation/tr-TR.json
+++ b/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).",
diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json
index a511b669e..b02de82e0 100644
--- a/internal/web/translation/uk-UA.json
+++ b/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-вузли).",
diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json
index 88afded80..e36249c8e 100644
--- a/internal/web/translation/vi-VN.json
+++ b/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).",
diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json
index 5787bb037..f2aeb6f19 100644
--- a/internal/web/translation/zh-CN.json
+++ b/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 节点)。",
diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json
index 99a007626..93c743743 100644
--- a/internal/web/translation/zh-TW.json
+++ b/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 節點)。",