From 7ecd88b9e37cccfd900aa16f2a3956f15c36f695 Mon Sep 17 00:00:00 2001 From: n0ctal <4c866w5fn9@privaterelay.appleid.com> Date: Sat, 15 Aug 2026 19:03:42 +0500 Subject: [PATCH] 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 <293235942+n0ctal@users.noreply.github.com> --- docs/content/docs/en/reference/api/nodes.mdx | 144 +++++------- docs/public/openapi.json | 30 +++ frontend/public/openapi.json | 30 +++ frontend/src/pages/api-docs/endpoints.ts | 5 + internal/web/controller/node.go | 11 + internal/web/runtime/tls_client.go | 131 +++++++++++ internal/web/runtime/tls_client_test.go | 233 +++++++++++++++++++ internal/web/runtime/tls_client_wire_test.go | 162 +++++++++++++ internal/web/service/node_mtls.go | 18 ++ internal/web/service/node_mtls_test.go | 26 +++ internal/web/service/setting_mtls.go | 19 ++ internal/web/translation/ar-EG.json | 3 +- internal/web/translation/en-US.json | 3 +- internal/web/translation/es-ES.json | 3 +- internal/web/translation/fa-IR.json | 3 +- internal/web/translation/id-ID.json | 3 +- internal/web/translation/ja-JP.json | 3 +- internal/web/translation/pt-BR.json | 3 +- internal/web/translation/ru-RU.json | 3 +- internal/web/translation/tr-TR.json | 3 +- internal/web/translation/uk-UA.json | 3 +- internal/web/translation/vi-VN.json | 3 +- internal/web/translation/zh-CN.json | 3 +- internal/web/translation/zh-TW.json | 3 +- 24 files changed, 747 insertions(+), 101 deletions(-) create mode 100644 internal/web/runtime/tls_client_wire_test.go 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 節點)。",