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>
This commit is contained in:
n0ctal
2026-08-15 19:03:42 +05:00
committed by GitHub
parent 1230559e69
commit 7ecd88b9e3
24 changed files with 747 additions and 101 deletions
+56 -88
View File
@@ -1,51 +1,40 @@
--- ---
title: Nodes title: Nodes
description: >- description: Manage remote 3x-ui panels acting as nodes for a central panel. All
Manage remote 3x-ui panels acting as nodes for a central panel. All endpoints endpoints under /panel/api/nodes.
under /panel/api/nodes.
full: true full: true
_openapi: _openapi:
preload: preload:
- ./public/openapi.json - ./public/openapi.json
toc: toc:
- depth: 2 - depth: 2
title: >- title: List every configured node with its connection details, health, and last
List every configured node with its connection details, health, and last
heartbeat patch. heartbeat patch.
url: >- url: '#list-every-configured-node-with-its-connection-details-health-and-last-heartbeat-patch'
#list-every-configured-node-with-its-connection-details-health-and-last-heartbeat-patch
- depth: 2 - depth: 2
title: >- title: This panel's node-auth CA certificate (public, PEM) to paste into a
This panel's node-auth CA certificate (public, PEM) to paste into a
node's mTLS trust setting. Lazily mints the CA and the master client node's mTLS trust setting. Lazily mints the CA and the master client
cert on first call. Pair with setting tlsVerifyMode=mtls on the node. cert on first call. Pair with setting tlsVerifyMode=mtls on the node.
url: >- 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'
#this-panels-node-auth-ca-certificate-public-pem-to-paste-into-a-nodes-mtls-trust-setting-lazily-mints-the-ca-and-the-master-client-cert-on-first-call-pair-with-setting-tlsverifymodemtls-on-the-node
- depth: 2 - depth: 2
title: >- title: Set the CA certificate this panel trusts for incoming node-API client
Set the CA certificate this panel trusts for incoming node-API client
certificates (this panel acting as a node). Paste the managing panel's certificates (this panel acting as a node). Paste the managing panel's
CA (from nodes/mtls/ca). An empty caCert disables it. A non-empty value CA (from nodes/mtls/ca). An empty caCert disables it. A non-empty value
must be a PEM certificate. Applied on the next panel restart. must be a PEM certificate. Applied on the next panel restart.
url: >- 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'
#set-the-ca-certificate-this-panel-trusts-for-incoming-node-api-client-certificates-this-panel-acting-as-a-node-paste-the-managing-panels-ca-from-nodesmtlsca-an-empty-cacert-disables-it-a-non-empty-value-must-be-a-pem-certificate-applied-on-the-next-panel-restart
- depth: 2 - depth: 2
title: Fetch a single node by ID. title: Fetch a single node by ID.
url: '#fetch-a-single-node-by-id' url: '#fetch-a-single-node-by-id'
- depth: 2 - depth: 2
title: >- title: Fetch a node's own web TLS certificate/key file paths (proxied to the
Fetch a node's own web TLS certificate/key file paths (proxied to the
node). Used by the inbound form's "Set Cert from Panel" so a node). Used by the inbound form's "Set Cert from Panel" so a
node-assigned inbound gets paths that exist on the node, not the central node-assigned inbound gets paths that exist on the node, not the central
panel. panel.
url: >- 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'
#fetch-a-nodes-own-web-tls-certificatekey-file-paths-proxied-to-the-node-used-by-the-inbound-forms-set-cert-from-panel-so-a-node-assigned-inbound-gets-paths-that-exist-on-the-node-not-the-central-panel
- depth: 2 - depth: 2
title: >- title: Register a new remote node. Provide its URL, apiToken, and optional
Register a new remote node. Provide its URL, apiToken, and optional
remark / allowPrivateAddress flag. remark / allowPrivateAddress flag.
url: >- url: '#register-a-new-remote-node-provide-its-url-apitoken-and-optional-remark--allowprivateaddress-flag'
#register-a-new-remote-node-provide-its-url-apitoken-and-optional-remark--allowprivateaddress-flag
- depth: 2 - depth: 2
title: Replace a nodes connection details. Same body shape as /add. title: Replace a nodes connection details. Same body shape as /add.
url: '#replace-a-nodes-connection-details-same-body-shape-as-add' url: '#replace-a-nodes-connection-details-same-body-shape-as-add'
@@ -56,115 +45,94 @@ _openapi:
title: Pause or resume traffic sync with this node. title: Pause or resume traffic sync with this node.
url: '#pause-or-resume-traffic-sync-with-this-node' url: '#pause-or-resume-traffic-sync-with-this-node'
- depth: 2 - depth: 2
title: >- title: Probe a node without saving it. Uses the body as connection details and
Probe a node without saving it. Uses the body as connection details and
returns the same heartbeat snapshot a registered node would have. returns the same heartbeat snapshot a registered node would have.
url: >- url: '#probe-a-node-without-saving-it-uses-the-body-as-connection-details-and-returns-the-same-heartbeat-snapshot-a-registered-node-would-have'
#probe-a-node-without-saving-it-uses-the-body-as-connection-details-and-returns-the-same-heartbeat-snapshot-a-registered-node-would-have
- depth: 2 - depth: 2
title: >- title: Connect to the node over HTTPS without verifying its certificate and
Connect to the node over HTTPS without verifying its certificate and
return the leaf certificate's SHA-256 (base64). Used by the Add/Edit return the leaf certificate's SHA-256 (base64). Used by the Add/Edit
Node dialog to fetch and pin a self-signed certificate. Uses the same Node dialog to fetch and pin a self-signed certificate. Uses the same
body as /test. body as /test.
url: >- 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'
#connect-to-the-node-over-https-without-verifying-its-certificate-and-return-the-leaf-certificates-sha-256-base64-used-by-the-addedit-node-dialog-to-fetch-and-pin-a-self-signed-certificate-uses-the-same-body-as-test
- depth: 2 - depth: 2
title: >- title: Use unsaved node connection details to list the remote inbounds available
Use unsaved node connection details to list the remote inbounds for selective import.
available for selective import. url: '#use-unsaved-node-connection-details-to-list-the-remote-inbounds-available-for-selective-import'
url: >-
#use-unsaved-node-connection-details-to-list-the-remote-inbounds-available-for-selective-import
- depth: 2 - depth: 2
title: Probe an existing node, updating its cached health state. title: Probe an existing node, updating its cached health state.
url: '#probe-an-existing-node-updating-its-cached-health-state' url: '#probe-an-existing-node-updating-its-cached-health-state'
- depth: 2 - depth: 2
title: >- title: 'Trigger the official panel self-updater on each given node (downloads
Trigger the official panel self-updater on each given node (downloads
the latest release and restarts). Only enabled, online nodes are the latest release and restarts). Only enabled, online nodes are
updated; offline/disabled ones are reported as skipped. Set "dev": true updated; offline/disabled ones are reported as skipped. Set "dev": true
to move the nodes to the rolling per-commit dev channel instead of the to move the nodes to the rolling per-commit dev channel instead of the
latest stable release. Returns a per-node result list. latest stable release. Returns a per-node result list.'
url: >- 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'
#trigger-the-official-panel-self-updater-on-each-given-node-downloads-the-latest-release-and-restarts-only-enabled-online-nodes-are-updated-offlinedisabled-ones-are-reported-as-skipped-set-dev-true-to-move-the-nodes-to-the-rolling-per-commit-dev-channel-instead-of-the-latest-stable-release-returns-a-per-node-result-list
- depth: 2 - depth: 2
title: >- title: Aggregated metric history for a node — same shape as /server/history,
Aggregated metric history for a node — same shape as /server/history,
scoped to one node. scoped to one node.
url: >- url: '#aggregated-metric-history-for-a-node--same-shape-as-serverhistory-scoped-to-one-node'
#aggregated-metric-history-for-a-node--same-shape-as-serverhistory-scoped-to-one-node - depth: 2
title: Validate the stored master mTLS client credential and invalidate cached
transports. Each transport closes its old idle pool and rebuilds with
the rotated certificate before its next request.
url: '#validate-the-stored-master-mtls-client-credential-and-invalidate-cached-transports-each-transport-closes-its-old-idle-pool-and-rebuilds-with-the-rotated-certificate-before-its-next-request'
structuredData: structuredData:
headings: headings:
- content: >- - content: List every configured node with its connection details, health, and
List every configured node with its connection details, health, and
last heartbeat patch. last heartbeat patch.
id: >- id: list-every-configured-node-with-its-connection-details-health-and-last-heartbeat-patch
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
- content: >-
This panel's node-auth CA certificate (public, PEM) to paste into a
node's mTLS trust setting. Lazily mints the CA and the master client node's mTLS trust setting. Lazily mints the CA and the master client
cert on first call. Pair with setting tlsVerifyMode=mtls on the node. cert on first call. Pair with setting tlsVerifyMode=mtls on the node.
id: >- 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
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
- content: >-
Set the CA certificate this panel trusts for incoming node-API client
certificates (this panel acting as a node). Paste the managing panel's certificates (this panel acting as a node). Paste the managing panel's
CA (from nodes/mtls/ca). An empty caCert disables it. A non-empty CA (from nodes/mtls/ca). An empty caCert disables it. A non-empty
value must be a PEM certificate. Applied on the next panel restart. value must be a PEM certificate. Applied on the next panel restart.
id: >- 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
set-the-ca-certificate-this-panel-trusts-for-incoming-node-api-client-certificates-this-panel-acting-as-a-node-paste-the-managing-panels-ca-from-nodesmtlsca-an-empty-cacert-disables-it-a-non-empty-value-must-be-a-pem-certificate-applied-on-the-next-panel-restart
- content: Fetch a single node by ID. - content: Fetch a single node by ID.
id: fetch-a-single-node-by-id id: fetch-a-single-node-by-id
- content: >- - content: Fetch a node's own web TLS certificate/key file paths (proxied to the
Fetch a node's own web TLS certificate/key file paths (proxied to the
node). Used by the inbound form's "Set Cert from Panel" so a node). Used by the inbound form's "Set Cert from Panel" so a
node-assigned inbound gets paths that exist on the node, not the node-assigned inbound gets paths that exist on the node, not the
central panel. central panel.
id: >- 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
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
- content: >-
Register a new remote node. Provide its URL, apiToken, and optional
remark / allowPrivateAddress flag. remark / allowPrivateAddress flag.
id: >- id: register-a-new-remote-node-provide-its-url-apitoken-and-optional-remark--allowprivateaddress-flag
register-a-new-remote-node-provide-its-url-apitoken-and-optional-remark--allowprivateaddress-flag
- content: Replace a nodes connection details. Same body shape as /add. - content: Replace a nodes connection details. Same body shape as /add.
id: replace-a-nodes-connection-details-same-body-shape-as-add id: replace-a-nodes-connection-details-same-body-shape-as-add
- content: Delete a node. Inbounds bound to it are not auto-migrated. - content: Delete a node. Inbounds bound to it are not auto-migrated.
id: delete-a-node-inbounds-bound-to-it-are-not-auto-migrated id: delete-a-node-inbounds-bound-to-it-are-not-auto-migrated
- content: Pause or resume traffic sync with this node. - content: Pause or resume traffic sync with this node.
id: pause-or-resume-traffic-sync-with-this-node id: pause-or-resume-traffic-sync-with-this-node
- content: >- - content: Probe a node without saving it. Uses the body as connection details and
Probe a node without saving it. Uses the body as connection details returns the same heartbeat snapshot a registered node would have.
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
id: >- - content: Connect to the node over HTTPS without verifying its certificate and
probe-a-node-without-saving-it-uses-the-body-as-connection-details-and-returns-the-same-heartbeat-snapshot-a-registered-node-would-have
- content: >-
Connect to the node over HTTPS without verifying its certificate and
return the leaf certificate's SHA-256 (base64). Used by the Add/Edit return the leaf certificate's SHA-256 (base64). Used by the Add/Edit
Node dialog to fetch and pin a self-signed certificate. Uses the same Node dialog to fetch and pin a self-signed certificate. Uses the same
body as /test. body as /test.
id: >- 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
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
- content: >-
Use unsaved node connection details to list the remote inbounds
available for selective import. available for selective import.
id: >- id: use-unsaved-node-connection-details-to-list-the-remote-inbounds-available-for-selective-import
use-unsaved-node-connection-details-to-list-the-remote-inbounds-available-for-selective-import
- content: Probe an existing node, updating its cached health state. - content: Probe an existing node, updating its cached health state.
id: probe-an-existing-node-updating-its-cached-health-state id: probe-an-existing-node-updating-its-cached-health-state
- content: >- - content: 'Trigger the official panel self-updater on each given node (downloads
Trigger the official panel self-updater on each given node (downloads
the latest release and restarts). Only enabled, online nodes are the latest release and restarts). Only enabled, online nodes are
updated; offline/disabled ones are reported as skipped. Set "dev": updated; offline/disabled ones are reported as skipped. Set "dev":
true to move the nodes to the rolling per-commit dev channel instead true to move the nodes to the rolling per-commit dev channel instead
of the latest stable release. Returns a per-node result list. of the latest stable release. Returns a per-node result list.'
id: >- 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
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,
- content: >-
Aggregated metric history for a node — same shape as /server/history,
scoped to one node. scoped to one node.
id: >- id: aggregated-metric-history-for-a-node--same-shape-as-serverhistory-scoped-to-one-node
aggregated-metric-history-for-a-node--same-shape-as-serverhistory-scoped-to-one-node - content: Validate the stored master mTLS client credential and invalidate cached
transports. Each transport closes its old idle pool and rebuilds with
the rotated certificate before its next request.
id: validate-the-stored-master-mtls-client-credential-and-invalidate-cached-transports-each-transport-closes-its-old-idle-pool-and-rebuilds-with-the-rotated-certificate-before-its-next-request
contents: [] contents: []
--- ---
@@ -177,7 +145,7 @@ export default function Layout(props) {
return ( return (
<> <>
{props.children} {props.children}
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/nodes/list","method":"get"},{"path":"/panel/api/nodes/mtls/ca","method":"post"},{"path":"/panel/api/nodes/mtls/trustCA","method":"post"},{"path":"/panel/api/nodes/get/{id}","method":"get"},{"path":"/panel/api/nodes/webCert/{id}","method":"get"},{"path":"/panel/api/nodes/add","method":"post"},{"path":"/panel/api/nodes/update/{id}","method":"post"},{"path":"/panel/api/nodes/del/{id}","method":"post"},{"path":"/panel/api/nodes/setEnable/{id}","method":"post"},{"path":"/panel/api/nodes/test","method":"post"},{"path":"/panel/api/nodes/certFingerprint","method":"post"},{"path":"/panel/api/nodes/inbounds","method":"post"},{"path":"/panel/api/nodes/probe/{id}","method":"post"},{"path":"/panel/api/nodes/updatePanel","method":"post"},{"path":"/panel/api/nodes/history/{id}/{metric}/{bucket}","method":"get"}]} showTitle /> <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/nodes/list","method":"get"},{"path":"/panel/api/nodes/mtls/ca","method":"post"},{"path":"/panel/api/nodes/mtls/trustCA","method":"post"},{"path":"/panel/api/nodes/get/{id}","method":"get"},{"path":"/panel/api/nodes/webCert/{id}","method":"get"},{"path":"/panel/api/nodes/add","method":"post"},{"path":"/panel/api/nodes/update/{id}","method":"post"},{"path":"/panel/api/nodes/del/{id}","method":"post"},{"path":"/panel/api/nodes/setEnable/{id}","method":"post"},{"path":"/panel/api/nodes/test","method":"post"},{"path":"/panel/api/nodes/certFingerprint","method":"post"},{"path":"/panel/api/nodes/inbounds","method":"post"},{"path":"/panel/api/nodes/probe/{id}","method":"post"},{"path":"/panel/api/nodes/updatePanel","method":"post"},{"path":"/panel/api/nodes/history/{id}/{metric}/{bucket}","method":"get"},{"path":"/panel/api/nodes/mtls/reloadClient","method":"post"}]} showTitle />
</> </>
); );
} }
+30
View File
@@ -10170,6 +10170,36 @@
} }
} }
} }
},
"/panel/api/nodes/mtls/reloadClient": {
"post": {
"tags": [
"Nodes"
],
"summary": "Validate the stored master mTLS client credential and invalidate cached transports. Each transport closes its old idle pool and rebuilds with the rotated certificate before its next request.",
"operationId": "post_panel_api_nodes_mtls_reloadClient",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
}
}
}
}
}
}
} }
} }
} }
+30
View File
@@ -8086,6 +8086,36 @@
} }
} }
}, },
"/panel/api/nodes/mtls/reloadClient": {
"post": {
"tags": [
"Nodes"
],
"summary": "Validate the stored master mTLS client credential and invalidate cached transports. Each transport closes its old idle pool and rebuilds with the rotated certificate before its next request.",
"operationId": "post_panel_api_nodes_mtls_reloadClient",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
}
}
}
}
}
}
},
"/panel/api/nodes/get/{id}": { "/panel/api/nodes/get/{id}": {
"get": { "get": {
"tags": [ "tags": [
+5
View File
@@ -935,6 +935,11 @@ export const sections: readonly Section[] = [
summary: "Set the CA certificate this panel trusts for incoming node-API client certificates (this panel acting as a node). Paste the managing panel's CA (from nodes/mtls/ca). An empty caCert disables it. A non-empty value must be a PEM certificate. Applied on the next panel restart.", summary: "Set the CA certificate this panel trusts for incoming node-API client certificates (this panel acting as a node). Paste the managing panel's CA (from nodes/mtls/ca). An empty caCert disables it. A non-empty value must be a PEM certificate. Applied on the next panel restart.",
body: '{\n "caCert": "-----BEGIN CERTIFICATE-----\\n...\\n-----END CERTIFICATE-----\\n"\n}', body: '{\n "caCert": "-----BEGIN CERTIFICATE-----\\n...\\n-----END CERTIFICATE-----\\n"\n}',
}, },
{
method: 'POST',
path: '/panel/api/nodes/mtls/reloadClient',
summary: 'Validate the stored master mTLS client credential and invalidate cached transports. Each transport closes its old idle pool and rebuilds with the rotated certificate before its next request.',
},
{ {
method: 'GET', method: 'GET',
path: '/panel/api/nodes/get/:id', path: '/panel/api/nodes/get/:id',
+11
View File
@@ -44,6 +44,17 @@ func (a *NodeController) initRouter(g *gin.RouterGroup) {
g.GET("/history/:id/:metric/:bucket", a.history) g.GET("/history/:id/:metric/:bucket", a.history)
g.POST("/mtls/ca", a.mtlsCa) g.POST("/mtls/ca", a.mtlsCa)
g.POST("/mtls/trustCA", a.setMtlsTrustCA) g.POST("/mtls/trustCA", a.setMtlsTrustCA)
g.POST("/mtls/reloadClient", a.reloadMtlsClient)
}
// reloadMtlsClient validates the credential currently stored by the master and
// closes cached mTLS pools so subsequent node requests present the new leaf.
func (a *NodeController) reloadMtlsClient(c *gin.Context) {
if err := a.nodeService.ReloadMasterMtlsClient(); err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.reloadMtls"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.reloadMtls"), nil)
} }
// mtlsCa returns this panel's node-auth CA certificate (public) to paste into a // mtlsCa returns this panel's node-auth CA certificate (public) to paste into a
+131
View File
@@ -9,6 +9,7 @@ import (
"net/http" "net/http"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/database/model"
@@ -25,6 +26,7 @@ type MasterClientCertProvider func() (tls.Certificate, error)
var ( var (
masterClientCertMu sync.RWMutex masterClientCertMu sync.RWMutex
masterClientCert MasterClientCertProvider masterClientCert MasterClientCertProvider
masterCertEpoch atomic.Uint64
) )
// SetMasterClientCertProvider installs the provider used to obtain the master // SetMasterClientCertProvider installs the provider used to obtain the master
@@ -45,6 +47,91 @@ func getMasterClientCert() (tls.Certificate, error) {
return p() return p()
} }
// InvalidateMasterClientConnections advances the client-credential generation.
// Every cached mTLS transport observes the generation before its next request,
// replaces its TLS transport, and closes the old idle pool. Requests already
// in flight are not interrupted; no request that starts after invalidation can
// reuse a connection authenticated with the previous leaf.
func InvalidateMasterClientConnections() {
masterCertEpoch.Add(1)
}
// ReloadMasterClientConnections validates that the currently configured
// provider can load the master credential, then invalidates every cached mTLS
// transport. Operators that rotate the credential outside the process (for
// example by restoring settings) can call this without restarting the panel.
func ReloadMasterClientConnections() error {
if _, err := getMasterClientCert(); err != nil {
return err
}
InvalidateMasterClientConnections()
return nil
}
type idleClosingRoundTripper interface {
http.RoundTripper
CloseIdleConnections()
}
type credentialRotatingTransport struct {
mu sync.Mutex
generation uint64
current idleClosingRoundTripper
build func() (idleClosingRoundTripper, error)
}
func buildStableCredentialTransport(build func() (idleClosingRoundTripper, error)) (idleClosingRoundTripper, uint64, error) {
for {
before := masterCertEpoch.Load()
current, err := build()
if err != nil {
return nil, 0, err
}
after := masterCertEpoch.Load()
if before == after {
return current, after, nil
}
current.CloseIdleConnections()
}
}
func newCredentialRotatingTransport(build func() (idleClosingRoundTripper, error)) (*credentialRotatingTransport, error) {
current, generation, err := buildStableCredentialTransport(build)
if err != nil {
return nil, err
}
return &credentialRotatingTransport{
generation: generation,
current: current,
build: build,
}, nil
}
func (t *credentialRotatingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
t.mu.Lock()
if masterCertEpoch.Load() != t.generation {
next, generation, err := buildStableCredentialTransport(t.build)
if err != nil {
t.mu.Unlock()
return nil, err
}
previous := t.current
t.current = next
t.generation = generation
previous.CloseIdleConnections()
}
current := t.current
t.mu.Unlock()
return current.RoundTrip(req)
}
func (t *credentialRotatingTransport) CloseIdleConnections() {
t.mu.Lock()
current := t.current
t.mu.Unlock()
current.CloseIdleConnections()
}
// defaultNodeHTTPClient reaches nodes trusting the system CA store ("verify" // defaultNodeHTTPClient reaches nodes trusting the system CA store ("verify"
// mode or plain http); shared so connections pool across nodes. // mode or plain http); shared so connections pool across nodes.
var defaultNodeHTTPClient = &http.Client{ var defaultNodeHTTPClient = &http.Client{
@@ -62,6 +149,30 @@ func HTTPClientForNode(n *model.Node, proxyURL string) (*http.Client, error) {
mode = "verify" mode = "verify"
} }
if proxyURL != "" { if proxyURL != "" {
if mode == "mtls" && n.Scheme != "http" {
timeout := remoteHTTPTimeout
build := func() (idleClosingRoundTripper, error) {
client, err := netproxy.NewHTTPClient(proxyURL, remoteHTTPTimeout)
if err != nil {
return nil, err
}
transport, ok := client.Transport.(*http.Transport)
if !ok {
return nil, common.NewError("mtls proxy client transport does not support credential rotation")
}
tlsCfg, err := tlsConfigForNode(n)
if err != nil {
return nil, err
}
transport.TLSClientConfig = tlsCfg
return transport, nil
}
transport, err := newCredentialRotatingTransport(build)
if err != nil {
return nil, err
}
return &http.Client{Transport: transport, Timeout: timeout}, nil
}
client, err := netproxy.NewHTTPClient(proxyURL, remoteHTTPTimeout) client, err := netproxy.NewHTTPClient(proxyURL, remoteHTTPTimeout)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -83,6 +194,26 @@ func HTTPClientForNode(n *model.Node, proxyURL string) (*http.Client, error) {
if mode == "verify" || n.Scheme == "http" { if mode == "verify" || n.Scheme == "http" {
return defaultNodeHTTPClient, nil return defaultNodeHTTPClient, nil
} }
if mode == "mtls" {
build := func() (idleClosingRoundTripper, error) {
tlsCfg, err := tlsConfigForNode(n)
if err != nil {
return nil, err
}
return &http.Transport{
MaxIdleConns: 64,
MaxIdleConnsPerHost: 4,
IdleConnTimeout: 60 * time.Second,
DialContext: netsafe.SSRFGuardedDialContext,
TLSClientConfig: tlsCfg,
}, nil
}
transport, err := newCredentialRotatingTransport(build)
if err != nil {
return nil, err
}
return &http.Client{Transport: transport}, nil
}
tlsCfg, err := tlsConfigForNode(n) tlsCfg, err := tlsConfigForNode(n)
if err != nil { if err != nil {
return nil, err return nil, err
+233
View File
@@ -11,12 +11,245 @@ import (
"net/url" "net/url"
"strconv" "strconv"
"strings" "strings"
"sync"
"sync/atomic"
"testing" "testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/util/crypto" "github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
) )
type generationProbeTransport struct {
id string
closed atomic.Int32
}
func (t *generationProbeTransport) RoundTrip(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: http.NoBody,
Header: make(http.Header),
Request: &http.Request{},
}, nil
}
func (t *generationProbeTransport) CloseIdleConnections() {
t.closed.Add(1)
}
func TestCredentialRotatingTransportDropsOldPoolBeforeNextRequest(t *testing.T) {
var selected atomic.Pointer[generationProbeTransport]
oldTransport := &generationProbeTransport{id: "old"}
newTransport := &generationProbeTransport{id: "new"}
selected.Store(oldTransport)
rotating, err := newCredentialRotatingTransport(func() (idleClosingRoundTripper, error) {
return selected.Load(), nil
})
if err != nil {
t.Fatalf("newCredentialRotatingTransport: %v", err)
}
rotating.mu.Lock()
initial := rotating.current
rotating.mu.Unlock()
if initial != oldTransport {
t.Fatalf("initial transport = %p, want old %p", initial, oldTransport)
}
selected.Store(newTransport)
InvalidateMasterClientConnections()
req := httptest.NewRequest(http.MethodGet, "https://node.example.test/panel/api/server/status", nil)
resp, err := rotating.RoundTrip(req)
if err != nil {
t.Fatalf("RoundTrip after credential rotation: %v", err)
}
_ = resp.Body.Close()
rotating.mu.Lock()
current := rotating.current
rotating.mu.Unlock()
if current != newTransport {
t.Fatalf("transport after invalidation = %p, want new %p", current, newTransport)
}
if got := oldTransport.closed.Load(); got != 1 {
t.Fatalf("old transport CloseIdleConnections calls = %d, want 1", got)
}
}
func TestReloadMasterClientConnectionsValidatesProviderBeforeInvalidation(t *testing.T) {
before := masterCertEpoch.Load()
SetMasterClientCertProvider(func() (tls.Certificate, error) {
return tls.Certificate{}, context.Canceled
})
if err := ReloadMasterClientConnections(); err == nil {
t.Fatal("reload with an invalid provider unexpectedly succeeded")
}
if got := masterCertEpoch.Load(); got != before {
t.Fatalf("failed reload changed generation from %d to %d", before, got)
}
SetMasterClientCertProvider(func() (tls.Certificate, error) {
return masterCertForTest(t), nil
})
t.Cleanup(func() { SetMasterClientCertProvider(nil) })
if err := ReloadMasterClientConnections(); err != nil {
t.Fatalf("ReloadMasterClientConnections: %v", err)
}
if got := masterCertEpoch.Load(); got != before+1 {
t.Fatalf("successful reload generation = %d, want %d", got, before+1)
}
}
func TestCredentialRotatingTransportRejectsBuildAcrossInvalidation(t *testing.T) {
oldTransport := &generationProbeTransport{id: "old"}
newTransport := &generationProbeTransport{id: "new"}
var selected atomic.Pointer[generationProbeTransport]
selected.Store(oldTransport)
firstBuildCaptured := make(chan struct{})
releaseFirstBuild := make(chan struct{})
var once sync.Once
build := func() (idleClosingRoundTripper, error) {
captured := selected.Load()
once.Do(func() {
close(firstBuildCaptured)
<-releaseFirstBuild
})
return captured, nil
}
type result struct {
transport *credentialRotatingTransport
err error
}
resultCh := make(chan result, 1)
go func() {
transport, err := newCredentialRotatingTransport(build)
resultCh <- result{transport: transport, err: err}
}()
<-firstBuildCaptured
selected.Store(newTransport)
InvalidateMasterClientConnections()
close(releaseFirstBuild)
got := <-resultCh
if got.err != nil {
t.Fatalf("newCredentialRotatingTransport: %v", got.err)
}
got.transport.mu.Lock()
current := got.transport.current
got.transport.mu.Unlock()
if current != newTransport {
t.Fatalf("transport built across invalidation = %p, want new %p", current, newTransport)
}
if calls := oldTransport.closed.Load(); calls != 1 {
t.Fatalf("stale transport CloseIdleConnections calls = %d, want 1", calls)
}
}
func TestHTTPClientForNodeMTLSRebuildsTLSConfigAfterCredentialInvalidation(t *testing.T) {
oldCert := masterCertForTest(t)
newCert := masterCertForTest(t)
selected := oldCert
SetMasterClientCertProvider(func() (tls.Certificate, error) { return selected, nil })
t.Cleanup(func() { SetMasterClientCertProvider(nil) })
client, err := HTTPClientForNode(&model.Node{
Scheme: "https",
Address: "node.example.test",
Port: 443,
TlsVerifyMode: "mtls",
}, "")
if err != nil {
t.Fatalf("HTTPClientForNode: %v", err)
}
rotating, ok := client.Transport.(*credentialRotatingTransport)
if !ok {
t.Fatalf("transport = %T, want *credentialRotatingTransport", client.Transport)
}
leaf := func() []byte {
rotating.mu.Lock()
defer rotating.mu.Unlock()
transport, ok := rotating.current.(*http.Transport)
if !ok {
t.Fatalf("current transport = %T, want *http.Transport", rotating.current)
}
return transport.TLSClientConfig.Certificates[0].Certificate[0]
}
if got := leaf(); string(got) != string(oldCert.Certificate[0]) {
t.Fatal("initial TLS config does not contain the old credential")
}
selected = newCert
InvalidateMasterClientConnections()
ctx, cancel := context.WithCancel(context.Background())
cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://node.example.test/", nil)
if err != nil {
t.Fatalf("NewRequestWithContext: %v", err)
}
if _, err := client.Do(req); err == nil {
t.Fatal("canceled request unexpectedly succeeded")
}
if got := leaf(); string(got) != string(newCert.Certificate[0]) {
t.Fatal("TLS config retained the old credential after invalidation")
}
}
func TestHTTPClientForNodeProxyMTLSRebuildKeepsProxyAndNewCredential(t *testing.T) {
oldCert := masterCertForTest(t)
newCert := masterCertForTest(t)
selected := oldCert
SetMasterClientCertProvider(func() (tls.Certificate, error) { return selected, nil })
t.Cleanup(func() { SetMasterClientCertProvider(nil) })
const proxyURL = "http://127.0.0.1:18080"
client, err := HTTPClientForNode(&model.Node{Scheme: "https", TlsVerifyMode: "mtls"}, proxyURL)
if err != nil {
t.Fatalf("HTTPClientForNode: %v", err)
}
rotating, ok := client.Transport.(*credentialRotatingTransport)
if !ok {
t.Fatalf("transport = %T, want rotating transport", client.Transport)
}
current := func() *http.Transport {
rotating.mu.Lock()
defer rotating.mu.Unlock()
transport, ok := rotating.current.(*http.Transport)
if !ok {
t.Fatalf("current transport = %T, want *http.Transport", rotating.current)
}
return transport
}
assertProxy := func(transport *http.Transport) {
t.Helper()
if transport.Proxy == nil {
t.Fatalf("proxy function is nil, want %s", proxyURL)
}
req, _ := http.NewRequest(http.MethodGet, "https://node.example.test/", nil)
got, err := transport.Proxy(req)
if err != nil || got == nil || got.String() != proxyURL {
t.Fatalf("proxy = %v, error = %v, want %s", got, err, proxyURL)
}
}
assertProxy(current())
selected = newCert
InvalidateMasterClientConnections()
ctx, cancel := context.WithCancel(context.Background())
cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://node.example.test/", nil)
_, _ = client.Do(req)
rebuilt := current()
assertProxy(rebuilt)
if got := rebuilt.TLSClientConfig.Certificates[0].Certificate[0]; string(got) != string(newCert.Certificate[0]) {
t.Fatal("proxy mTLS rebuild retained the old credential")
}
}
// masterCertForTest builds a real CA-signed client certificate for mtls tests. // masterCertForTest builds a real CA-signed client certificate for mtls tests.
func masterCertForTest(t *testing.T) tls.Certificate { func masterCertForTest(t *testing.T) tls.Certificate {
t.Helper() t.Helper()
@@ -0,0 +1,162 @@
package runtime
import (
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"io"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
)
type wireObservation struct {
pin string
remoteAddr string
}
func startLeafRecordingServer(t *testing.T) (*httptest.Server, *x509.CertPool, func() []wireObservation) {
t.Helper()
var mu sync.Mutex
var seen []wireObservation
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
observation := wireObservation{remoteAddr: r.RemoteAddr}
if r.TLS != nil && len(r.TLS.PeerCertificates) > 0 {
sum := sha256.Sum256(r.TLS.PeerCertificates[0].Raw)
observation.pin = hex.EncodeToString(sum[:])
}
mu.Lock()
seen = append(seen, observation)
mu.Unlock()
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}))
srv.TLS = &tls.Config{ClientAuth: tls.RequestClientCert}
srv.StartTLS()
t.Cleanup(srv.Close)
pool := x509.NewCertPool()
pool.AddCert(srv.Certificate())
return srv, pool, func() []wireObservation {
mu.Lock()
defer mu.Unlock()
result := make([]wireObservation, len(seen))
copy(result, seen)
return result
}
}
func pinOf(t *testing.T, cert tls.Certificate) string {
t.Helper()
sum := sha256.Sum256(cert.Certificate[0])
return hex.EncodeToString(sum[:])
}
func rotatingClientForTest(t *testing.T, roots *x509.CertPool) *http.Client {
t.Helper()
build := func() (idleClosingRoundTripper, error) {
cert, err := getMasterClientCert()
if err != nil {
return nil, err
}
return &http.Transport{
MaxIdleConns: 64,
MaxIdleConnsPerHost: 4,
IdleConnTimeout: 60 * time.Second,
TLSClientConfig: &tls.Config{
Certificates: []tls.Certificate{cert},
RootCAs: roots,
MinVersion: tls.VersionTLS12,
},
}, nil
}
transport, err := newCredentialRotatingTransport(build)
if err != nil {
t.Fatalf("newCredentialRotatingTransport: %v", err)
}
return &http.Client{Transport: transport, Timeout: 10 * time.Second}
}
func doWireRequest(t *testing.T, client *http.Client, url string) {
t.Helper()
response, err := client.Get(url)
if err != nil {
t.Fatalf("request: %v", err)
}
_, _ = io.Copy(io.Discard, response.Body)
_ = response.Body.Close()
if response.StatusCode != http.StatusOK {
t.Fatalf("status=%d want=%d", response.StatusCode, http.StatusOK)
}
}
func TestCredentialRotationPresentsNewLeafOnNextConnection(t *testing.T) {
server, roots, observations := startLeafRecordingServer(t)
oldCert := masterCertForTest(t)
newCert := masterCertForTest(t)
oldPin := pinOf(t, oldCert)
newPin := pinOf(t, newCert)
if oldPin == newPin {
t.Fatal("test fixture produced identical leaves")
}
var providerMu sync.Mutex
current := oldCert
SetMasterClientCertProvider(func() (tls.Certificate, error) {
providerMu.Lock()
defer providerMu.Unlock()
return current, nil
})
t.Cleanup(func() { SetMasterClientCertProvider(nil) })
client := rotatingClientForTest(t, roots)
doWireRequest(t, client, server.URL)
doWireRequest(t, client, server.URL)
baseline := observations()
if len(baseline) != 2 || baseline[0].pin != oldPin || baseline[1].pin != oldPin {
t.Fatalf("baseline=%v", baseline)
}
if baseline[0].remoteAddr != baseline[1].remoteAddr {
t.Fatalf("baseline connections differ: %v", baseline)
}
providerMu.Lock()
current = newCert
providerMu.Unlock()
InvalidateMasterClientConnections()
doWireRequest(t, client, server.URL)
after := observations()
if len(after) != 3 || after[2].pin != newPin {
t.Fatalf("rotation observations=%v want new leaf=%s", after, newPin)
}
if after[2].remoteAddr == baseline[1].remoteAddr {
t.Fatalf("rotated request reused stale connection %s", after[2].remoteAddr)
}
}
func TestCredentialRotationControlKeepsOldLeafWithoutInvalidation(t *testing.T) {
server, roots, observations := startLeafRecordingServer(t)
oldCert := masterCertForTest(t)
newCert := masterCertForTest(t)
oldPin := pinOf(t, oldCert)
var providerMu sync.Mutex
current := oldCert
SetMasterClientCertProvider(func() (tls.Certificate, error) {
providerMu.Lock()
defer providerMu.Unlock()
return current, nil
})
t.Cleanup(func() { SetMasterClientCertProvider(nil) })
client := rotatingClientForTest(t, roots)
doWireRequest(t, client, server.URL)
providerMu.Lock()
current = newCert
providerMu.Unlock()
doWireRequest(t, client, server.URL)
got := observations()
if len(got) != 2 || got[1].pin != oldPin {
t.Fatalf("control observations=%v want stale leaf=%s", got, oldPin)
}
if got[0].remoteAddr != got[1].remoteAddr {
t.Fatalf("control did not reuse connection: %v", got)
}
}
+18
View File
@@ -1,11 +1,13 @@
package service package service
import ( import (
"crypto/tls"
"crypto/x509" "crypto/x509"
"encoding/pem" "encoding/pem"
"strings" "strings"
"github.com/mhsanaei/3x-ui/v3/internal/util/common" "github.com/mhsanaei/3x-ui/v3/internal/util/common"
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
) )
// NodeMtlsCaCert returns the PEM of this panel's node-auth CA certificate (the // NodeMtlsCaCert returns the PEM of this panel's node-auth CA certificate (the
@@ -24,6 +26,22 @@ func (s *NodeService) NodeMtlsCaCert() (string, error) {
return string(ca.CertPEM), nil return string(ca.CertPEM), nil
} }
// ReloadMasterMtlsClient validates the master credential currently stored by
// the panel and drops cached mTLS connection pools. This makes an intentional
// out-of-process credential rotation take effect without restarting x-ui (and
// therefore without stopping the xray child process in the same service).
func (s *NodeService) ReloadMasterMtlsClient() error {
stored, err := (&SettingService{}).LoadMasterClientCert()
if err != nil {
return err
}
if _, err := tls.X509KeyPair(stored.CertPEM, stored.KeyPEM); err != nil {
return err
}
runtime.InvalidateMasterClientConnections()
return nil
}
// SetNodeMtlsTrustCA stores the CA certificate this panel trusts for incoming // SetNodeMtlsTrustCA stores the CA certificate this panel trusts for incoming
// node-API client certificates. An empty value clears it (mTLS off). A // node-API client certificates. An empty value clears it (mTLS off). A
// non-empty value must be a PEM certificate (fail closed). Takes effect on the // non-empty value must be a PEM certificate (fail closed). Takes effect on the
+26
View File
@@ -1,15 +1,41 @@
package service package service
import ( import (
"crypto/tls"
"crypto/x509" "crypto/x509"
"encoding/pem" "encoding/pem"
"testing" "testing"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
) )
func TestReloadMasterMtlsClientDoesNotMintMissingCredential(t *testing.T) {
_ = setupSettingMtlsDB(t)
runtime.SetMasterClientCertProvider(func() (tls.Certificate, error) {
pair, err := (&SettingService{}).EnsureMasterClientCert()
if err != nil {
return tls.Certificate{}, err
}
return tls.X509KeyPair(pair.CertPEM, pair.KeyPEM)
})
t.Cleanup(func() { runtime.SetMasterClientCertProvider(nil) })
if err := (&NodeService{}).ReloadMasterMtlsClient(); err == nil {
t.Fatal("reload on a fresh database unexpectedly succeeded")
}
var count int64
keys := []string{settingNodeMtlsCaCert, settingNodeMtlsCaKey, settingNodeMtlsClientCert, settingNodeMtlsClientKey}
if err := database.GetDB().Model(&model.Setting{}).Where("key IN ?", keys).Count(&count).Error; err != nil {
t.Fatalf("count mTLS settings: %v", err)
}
if count != 0 {
t.Fatalf("reload created %d mTLS setting rows, want 0", count)
}
}
func TestNormalizeKeepsMtls(t *testing.T) { func TestNormalizeKeepsMtls(t *testing.T) {
s := &NodeService{} s := &NodeService{}
cases := []struct { cases := []struct {
+19
View File
@@ -15,6 +15,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/util/common" "github.com/mhsanaei/3x-ui/v3/internal/util/common"
"github.com/mhsanaei/3x-ui/v3/internal/util/crypto" "github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
) )
var masterClientCredentialMu sync.Mutex var masterClientCredentialMu sync.Mutex
@@ -133,6 +134,7 @@ func (s *SettingService) EnsureMasterClientCert() (crypto.CertKeyPEM, error) {
if err := saveMasterClientCredential(client, pin); err != nil { if err := saveMasterClientCredential(client, pin); err != nil {
return crypto.CertKeyPEM{}, err return crypto.CertKeyPEM{}, err
} }
runtime.InvalidateMasterClientConnections()
return client, nil return client, nil
} }
@@ -158,6 +160,23 @@ func saveMasterClientCredential(client crypto.CertKeyPEM, pin string) error {
}) })
} }
// LoadMasterClientCert returns only the already-persisted credential. It never
// mints or changes CA/client settings.
func (s *SettingService) LoadMasterClientCert() (crypto.CertKeyPEM, error) {
certPem, err := s.getString(settingNodeMtlsClientCert)
if err != nil {
return crypto.CertKeyPEM{}, err
}
keyPem, err := s.getString(settingNodeMtlsClientKey)
if err != nil {
return crypto.CertKeyPEM{}, err
}
if certPem == "" || keyPem == "" {
return crypto.CertKeyPEM{}, common.NewError("master client certificate is not fully configured")
}
return crypto.CertKeyPEM{CertPEM: []byte(certPem), KeyPEM: []byte(keyPem)}, nil
}
// NodeMtlsClientCAPool builds the trust pool used as the panel listener's // NodeMtlsClientCAPool builds the trust pool used as the panel listener's
// ClientCAs for incoming node-API client certificates. It returns (nil, nil) // ClientCAs for incoming node-API client certificates. It returns (nil, nil)
// when no trust CA is configured, so mTLS stays off and the listener behaves // when no trust CA is configured, so mTLS stays off and the listener behaves
+2 -1
View File
@@ -974,7 +974,8 @@
"updateStarted": "بدأ تحديث اللوحة", "updateStarted": "بدأ تحديث اللوحة",
"updateResult": "تم بدء التحديث على {ok} عقدة، فشل {failed}", "updateResult": "تم بدء التحديث على {ok} عقدة، فشل {failed}",
"updateNoneEligible": "اختر عقدة واحدة على الأقل متصلة ومفعّلة", "updateNoneEligible": "اختر عقدة واحدة على الأقل متصلة ومفعّلة",
"saveMtls": "حفظ mTLS النود" "saveMtls": "حفظ mTLS النود",
"reloadMtls": "Reload master mTLS credential"
}, },
"tlsVerifyMode": "التحقق من TLS", "tlsVerifyMode": "التحقق من TLS",
"tlsVerifyModeHint": "كيف يتحقق اللوحة من شهادة HTTPS الخاصة بالعقدة. التثبيت أو التخطّي مخصّصان للشهادات الموقّعة ذاتيًا (عُقد https فقط).", "tlsVerifyModeHint": "كيف يتحقق اللوحة من شهادة HTTPS الخاصة بالعقدة. التثبيت أو التخطّي مخصّصان للشهادات الموقّعة ذاتيًا (عُقد https فقط).",
+2 -1
View File
@@ -1091,7 +1091,8 @@
"updateStarted": "Panel update started", "updateStarted": "Panel update started",
"updateResult": "Update triggered on {ok} node(s), {failed} failed", "updateResult": "Update triggered on {ok} node(s), {failed} failed",
"updateNoneEligible": "Select at least one online, enabled node", "updateNoneEligible": "Select at least one online, enabled node",
"saveMtls": "Save node mTLS" "saveMtls": "Save node mTLS",
"reloadMtls": "Reload master mTLS credential"
}, },
"tlsVerifyMode": "TLS verification", "tlsVerifyMode": "TLS verification",
"tlsVerifyModeHint": "How the panel validates the node's HTTPS certificate. Pin or Skip are for self-signed certs (https nodes only).", "tlsVerifyModeHint": "How the panel validates the node's HTTPS certificate. Pin or Skip are for self-signed certs (https nodes only).",
+2 -1
View File
@@ -974,7 +974,8 @@
"updateStarted": "Actualización del panel iniciada", "updateStarted": "Actualización del panel iniciada",
"updateResult": "Actualización iniciada en {ok} nodo(s), {failed} fallaron", "updateResult": "Actualización iniciada en {ok} nodo(s), {failed} fallaron",
"updateNoneEligible": "Selecciona al menos un nodo en línea y habilitado", "updateNoneEligible": "Selecciona al menos un nodo en línea y habilitado",
"saveMtls": "Guardar mTLS del nodo" "saveMtls": "Guardar mTLS del nodo",
"reloadMtls": "Reload master mTLS credential"
}, },
"tlsVerifyMode": "Verificación TLS", "tlsVerifyMode": "Verificación TLS",
"tlsVerifyModeHint": "Cómo valida el panel el certificado HTTPS del nodo. Fijar u Omitir son para certificados autofirmados (solo nodos https).", "tlsVerifyModeHint": "Cómo valida el panel el certificado HTTPS del nodo. Fijar u Omitir son para certificados autofirmados (solo nodos https).",
+2 -1
View File
@@ -974,7 +974,8 @@
"updateStarted": "به‌روزرسانی پنل آغاز شد", "updateStarted": "به‌روزرسانی پنل آغاز شد",
"updateResult": "به‌روزرسانی روی {ok} نود آغاز شد، {failed} ناموفق", "updateResult": "به‌روزرسانی روی {ok} نود آغاز شد، {failed} ناموفق",
"updateNoneEligible": "حداقل یک نود آنلاین و فعال انتخاب کنید", "updateNoneEligible": "حداقل یک نود آنلاین و فعال انتخاب کنید",
"saveMtls": "ذخیره mTLS نود" "saveMtls": "ذخیره mTLS نود",
"reloadMtls": "Reload master mTLS credential"
}, },
"tlsVerifyMode": "اعتبارسنجی TLS", "tlsVerifyMode": "اعتبارسنجی TLS",
"tlsVerifyModeHint": "اینکه پنل گواهی HTTPS نود را چطور بررسی کند. Pin یا Skip برای گواهی‌های self-signed است (فقط نودهای https).", "tlsVerifyModeHint": "اینکه پنل گواهی HTTPS نود را چطور بررسی کند. Pin یا Skip برای گواهی‌های self-signed است (فقط نودهای https).",
+2 -1
View File
@@ -974,7 +974,8 @@
"updateStarted": "Pembaruan panel dimulai", "updateStarted": "Pembaruan panel dimulai",
"updateResult": "Pembaruan dipicu pada {ok} node, {failed} gagal", "updateResult": "Pembaruan dipicu pada {ok} node, {failed} gagal",
"updateNoneEligible": "Pilih minimal satu node online dan aktif", "updateNoneEligible": "Pilih minimal satu node online dan aktif",
"saveMtls": "Simpan mTLS node" "saveMtls": "Simpan mTLS node",
"reloadMtls": "Reload master mTLS credential"
}, },
"tlsVerifyMode": "Verifikasi TLS", "tlsVerifyMode": "Verifikasi TLS",
"tlsVerifyModeHint": "Cara panel memvalidasi sertifikat HTTPS node. Pin atau Lewati untuk sertifikat self-signed (hanya node https).", "tlsVerifyModeHint": "Cara panel memvalidasi sertifikat HTTPS node. Pin atau Lewati untuk sertifikat self-signed (hanya node https).",
+2 -1
View File
@@ -974,7 +974,8 @@
"updateStarted": "パネルの更新を開始しました", "updateStarted": "パネルの更新を開始しました",
"updateResult": "{ok} 個のノードで更新を開始、{failed} 個失敗", "updateResult": "{ok} 個のノードで更新を開始、{failed} 個失敗",
"updateNoneEligible": "オンラインで有効なノードを少なくとも1つ選択してください", "updateNoneEligible": "オンラインで有効なノードを少なくとも1つ選択してください",
"saveMtls": "ノード mTLS を保存" "saveMtls": "ノード mTLS を保存",
"reloadMtls": "Reload master mTLS credential"
}, },
"tlsVerifyMode": "TLS 検証", "tlsVerifyMode": "TLS 検証",
"tlsVerifyModeHint": "パネルがノードの HTTPS 証明書を検証する方法。ピン留めやスキップは自己署名証明書向け(https ノードのみ)。", "tlsVerifyModeHint": "パネルがノードの HTTPS 証明書を検証する方法。ピン留めやスキップは自己署名証明書向け(https ノードのみ)。",
+2 -1
View File
@@ -974,7 +974,8 @@
"updateStarted": "Atualização do painel iniciada", "updateStarted": "Atualização do painel iniciada",
"updateResult": "Atualização iniciada em {ok} nó(s), {failed} falharam", "updateResult": "Atualização iniciada em {ok} nó(s), {failed} falharam",
"updateNoneEligible": "Selecione pelo menos um nó online e ativo", "updateNoneEligible": "Selecione pelo menos um nó online e ativo",
"saveMtls": "Salvar mTLS do nó" "saveMtls": "Salvar mTLS do nó",
"reloadMtls": "Reload master mTLS credential"
}, },
"tlsVerifyMode": "Verificação TLS", "tlsVerifyMode": "Verificação TLS",
"tlsVerifyModeHint": "Como o painel valida o certificado HTTPS do nó. Fixar ou Ignorar são para certificados autoassinados (apenas nós https).", "tlsVerifyModeHint": "Como o painel valida o certificado HTTPS do nó. Fixar ou Ignorar são para certificados autoassinados (apenas nós https).",
+2 -1
View File
@@ -974,7 +974,8 @@
"updateStarted": "Обновление панели запущено", "updateStarted": "Обновление панели запущено",
"updateResult": "Обновление запущено на {ok} узлах, {failed} не удалось", "updateResult": "Обновление запущено на {ok} узлах, {failed} не удалось",
"updateNoneEligible": "Выберите хотя бы один включённый узел в сети", "updateNoneEligible": "Выберите хотя бы один включённый узел в сети",
"saveMtls": "Сохранить mTLS узла" "saveMtls": "Сохранить mTLS узла",
"reloadMtls": "Reload master mTLS credential"
}, },
"tlsVerifyMode": "Проверка TLS", "tlsVerifyMode": "Проверка TLS",
"tlsVerifyModeHint": "Как панель проверяет HTTPS-сертификат узла. Закрепление или Пропуск — для самоподписанных сертификатов (только https-узлы).", "tlsVerifyModeHint": "Как панель проверяет HTTPS-сертификат узла. Закрепление или Пропуск — для самоподписанных сертификатов (только https-узлы).",
+2 -1
View File
@@ -974,7 +974,8 @@
"updateStarted": "Panel güncellemesi başlatıldı", "updateStarted": "Panel güncellemesi başlatıldı",
"updateResult": "{ok} düğümde güncelleme başlatıldı, {failed} başarısız", "updateResult": "{ok} düğümde güncelleme başlatıldı, {failed} başarısız",
"updateNoneEligible": "En az bir çevrimiçi ve etkin düğüm seçin", "updateNoneEligible": "En az bir çevrimiçi ve etkin düğüm seçin",
"saveMtls": "Düğüm mTLS kaydet" "saveMtls": "Düğüm mTLS kaydet",
"reloadMtls": "Reload master mTLS credential"
}, },
"tlsVerifyMode": "TLS Doğrulaması", "tlsVerifyMode": "TLS Doğrulaması",
"tlsVerifyModeHint": "Panelin düğümün HTTPS sertifikasını nasıl doğrulayacağını belirler. Sabitle veya Atla, kendinden imzalı sertifikalar içindir (yalnızca https düğümleri).", "tlsVerifyModeHint": "Panelin düğümün HTTPS sertifikasını nasıl doğrulayacağını belirler. Sabitle veya Atla, kendinden imzalı sertifikalar içindir (yalnızca https düğümleri).",
+2 -1
View File
@@ -974,7 +974,8 @@
"updateStarted": "Оновлення панелі розпочато", "updateStarted": "Оновлення панелі розпочато",
"updateResult": "Оновлення запущено на {ok} вузлах, {failed} не вдалося", "updateResult": "Оновлення запущено на {ok} вузлах, {failed} не вдалося",
"updateNoneEligible": "Виберіть принаймні один увімкнений вузол у мережі", "updateNoneEligible": "Виберіть принаймні один увімкнений вузол у мережі",
"saveMtls": "Зберегти mTLS вузла" "saveMtls": "Зберегти mTLS вузла",
"reloadMtls": "Reload master mTLS credential"
}, },
"tlsVerifyMode": "Перевірка TLS", "tlsVerifyMode": "Перевірка TLS",
"tlsVerifyModeHint": "Як панель перевіряє HTTPS-сертифікат вузла. Закріплення або Пропуск — для самопідписаних сертифікатів (лише https-вузли).", "tlsVerifyModeHint": "Як панель перевіряє HTTPS-сертифікат вузла. Закріплення або Пропуск — для самопідписаних сертифікатів (лише https-вузли).",
+2 -1
View File
@@ -974,7 +974,8 @@
"updateStarted": "Đã bắt đầu cập nhật bảng điều khiển", "updateStarted": "Đã bắt đầu cập nhật bảng điều khiển",
"updateResult": "Đã kích hoạt cập nhật trên {ok} node, {failed} thất bại", "updateResult": "Đã kích hoạt cập nhật trên {ok} node, {failed} thất bại",
"updateNoneEligible": "Chọn ít nhất một node trực tuyến và đang bật", "updateNoneEligible": "Chọn ít nhất một node trực tuyến và đang bật",
"saveMtls": "Lưu mTLS nút" "saveMtls": "Lưu mTLS nút",
"reloadMtls": "Reload master mTLS credential"
}, },
"tlsVerifyMode": "Xác minh TLS", "tlsVerifyMode": "Xác minh TLS",
"tlsVerifyModeHint": "Cách panel xác thực chứng chỉ HTTPS của node. Ghim hoặc Bỏ qua dành cho chứng chỉ tự ký (chỉ node https).", "tlsVerifyModeHint": "Cách panel xác thực chứng chỉ HTTPS của node. Ghim hoặc Bỏ qua dành cho chứng chỉ tự ký (chỉ node https).",
+2 -1
View File
@@ -974,7 +974,8 @@
"updateStarted": "已开始更新面板", "updateStarted": "已开始更新面板",
"updateResult": "已在 {ok} 个节点上触发更新,{failed} 个失败", "updateResult": "已在 {ok} 个节点上触发更新,{failed} 个失败",
"updateNoneEligible": "请至少选择一个在线且已启用的节点", "updateNoneEligible": "请至少选择一个在线且已启用的节点",
"saveMtls": "保存节点 mTLS" "saveMtls": "保存节点 mTLS",
"reloadMtls": "Reload master mTLS credential"
}, },
"tlsVerifyMode": "TLS 校验", "tlsVerifyMode": "TLS 校验",
"tlsVerifyModeHint": "面板如何校验节点的 HTTPS 证书。固定或跳过用于自签名证书(仅 https 节点)。", "tlsVerifyModeHint": "面板如何校验节点的 HTTPS 证书。固定或跳过用于自签名证书(仅 https 节点)。",
+2 -1
View File
@@ -974,7 +974,8 @@
"updateStarted": "已開始更新面板", "updateStarted": "已開始更新面板",
"updateResult": "已在 {ok} 個節點上觸發更新,{failed} 個失敗", "updateResult": "已在 {ok} 個節點上觸發更新,{failed} 個失敗",
"updateNoneEligible": "請至少選擇一個在線且已啟用的節點", "updateNoneEligible": "請至少選擇一個在線且已啟用的節點",
"saveMtls": "儲存節點 mTLS" "saveMtls": "儲存節點 mTLS",
"reloadMtls": "Reload master mTLS credential"
}, },
"tlsVerifyMode": "TLS 驗證", "tlsVerifyMode": "TLS 驗證",
"tlsVerifyModeHint": "面板如何驗證節點的 HTTPS 憑證。釘選或略過用於自簽憑證(僅 https 節點)。", "tlsVerifyModeHint": "面板如何驗證節點的 HTTPS 憑證。釘選或略過用於自簽憑證(僅 https 節點)。",