mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-15 15:50:59 +00:00
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:
@@ -1,51 +1,40 @@
|
||||
---
|
||||
title: Nodes
|
||||
description: >-
|
||||
Manage remote 3x-ui panels acting as nodes for a central panel. All endpoints
|
||||
under /panel/api/nodes.
|
||||
description: Manage remote 3x-ui panels acting as nodes for a central panel. All
|
||||
endpoints under /panel/api/nodes.
|
||||
full: true
|
||||
_openapi:
|
||||
preload:
|
||||
- ./public/openapi.json
|
||||
toc:
|
||||
- depth: 2
|
||||
title: >-
|
||||
List every configured node with its connection details, health, and last
|
||||
title: List every configured node with its connection details, health, and last
|
||||
heartbeat patch.
|
||||
url: >-
|
||||
#list-every-configured-node-with-its-connection-details-health-and-last-heartbeat-patch
|
||||
url: '#list-every-configured-node-with-its-connection-details-health-and-last-heartbeat-patch'
|
||||
- depth: 2
|
||||
title: >-
|
||||
This panel's node-auth CA certificate (public, PEM) to paste into a
|
||||
title: This panel's node-auth CA certificate (public, PEM) to paste into a
|
||||
node's mTLS trust setting. Lazily mints the CA and the master client
|
||||
cert on first call. Pair with setting tlsVerifyMode=mtls on the node.
|
||||
url: >-
|
||||
#this-panels-node-auth-ca-certificate-public-pem-to-paste-into-a-nodes-mtls-trust-setting-lazily-mints-the-ca-and-the-master-client-cert-on-first-call-pair-with-setting-tlsverifymodemtls-on-the-node
|
||||
url: '#this-panels-node-auth-ca-certificate-public-pem-to-paste-into-a-nodes-mtls-trust-setting-lazily-mints-the-ca-and-the-master-client-cert-on-first-call-pair-with-setting-tlsverifymodemtls-on-the-node'
|
||||
- depth: 2
|
||||
title: >-
|
||||
Set the CA certificate this panel trusts for incoming node-API client
|
||||
title: Set the CA certificate this panel trusts for incoming node-API client
|
||||
certificates (this panel acting as a node). Paste the managing panel's
|
||||
CA (from nodes/mtls/ca). An empty caCert disables it. A non-empty value
|
||||
must be a PEM certificate. Applied on the next panel restart.
|
||||
url: >-
|
||||
#set-the-ca-certificate-this-panel-trusts-for-incoming-node-api-client-certificates-this-panel-acting-as-a-node-paste-the-managing-panels-ca-from-nodesmtlsca-an-empty-cacert-disables-it-a-non-empty-value-must-be-a-pem-certificate-applied-on-the-next-panel-restart
|
||||
url: '#set-the-ca-certificate-this-panel-trusts-for-incoming-node-api-client-certificates-this-panel-acting-as-a-node-paste-the-managing-panels-ca-from-nodesmtlsca-an-empty-cacert-disables-it-a-non-empty-value-must-be-a-pem-certificate-applied-on-the-next-panel-restart'
|
||||
- depth: 2
|
||||
title: Fetch a single node by ID.
|
||||
url: '#fetch-a-single-node-by-id'
|
||||
- depth: 2
|
||||
title: >-
|
||||
Fetch a node's own web TLS certificate/key file paths (proxied to the
|
||||
title: Fetch a node's own web TLS certificate/key file paths (proxied to the
|
||||
node). Used by the inbound form's "Set Cert from Panel" so a
|
||||
node-assigned inbound gets paths that exist on the node, not the central
|
||||
panel.
|
||||
url: >-
|
||||
#fetch-a-nodes-own-web-tls-certificatekey-file-paths-proxied-to-the-node-used-by-the-inbound-forms-set-cert-from-panel-so-a-node-assigned-inbound-gets-paths-that-exist-on-the-node-not-the-central-panel
|
||||
url: '#fetch-a-nodes-own-web-tls-certificatekey-file-paths-proxied-to-the-node-used-by-the-inbound-forms-set-cert-from-panel-so-a-node-assigned-inbound-gets-paths-that-exist-on-the-node-not-the-central-panel'
|
||||
- depth: 2
|
||||
title: >-
|
||||
Register a new remote node. Provide its URL, apiToken, and optional
|
||||
title: Register a new remote node. Provide its URL, apiToken, and optional
|
||||
remark / allowPrivateAddress flag.
|
||||
url: >-
|
||||
#register-a-new-remote-node-provide-its-url-apitoken-and-optional-remark--allowprivateaddress-flag
|
||||
url: '#register-a-new-remote-node-provide-its-url-apitoken-and-optional-remark--allowprivateaddress-flag'
|
||||
- depth: 2
|
||||
title: Replace a node’s connection details. Same body shape as /add.
|
||||
url: '#replace-a-nodes-connection-details-same-body-shape-as-add'
|
||||
@@ -56,115 +45,94 @@ _openapi:
|
||||
title: Pause or resume traffic sync with this node.
|
||||
url: '#pause-or-resume-traffic-sync-with-this-node'
|
||||
- depth: 2
|
||||
title: >-
|
||||
Probe a node without saving it. Uses the body as connection details and
|
||||
title: Probe a node without saving it. Uses the body as connection details and
|
||||
returns the same heartbeat snapshot a registered node would have.
|
||||
url: >-
|
||||
#probe-a-node-without-saving-it-uses-the-body-as-connection-details-and-returns-the-same-heartbeat-snapshot-a-registered-node-would-have
|
||||
url: '#probe-a-node-without-saving-it-uses-the-body-as-connection-details-and-returns-the-same-heartbeat-snapshot-a-registered-node-would-have'
|
||||
- depth: 2
|
||||
title: >-
|
||||
Connect to the node over HTTPS without verifying its certificate and
|
||||
title: Connect to the node over HTTPS without verifying its certificate and
|
||||
return the leaf certificate's SHA-256 (base64). Used by the Add/Edit
|
||||
Node dialog to fetch and pin a self-signed certificate. Uses the same
|
||||
body as /test.
|
||||
url: >-
|
||||
#connect-to-the-node-over-https-without-verifying-its-certificate-and-return-the-leaf-certificates-sha-256-base64-used-by-the-addedit-node-dialog-to-fetch-and-pin-a-self-signed-certificate-uses-the-same-body-as-test
|
||||
url: '#connect-to-the-node-over-https-without-verifying-its-certificate-and-return-the-leaf-certificates-sha-256-base64-used-by-the-addedit-node-dialog-to-fetch-and-pin-a-self-signed-certificate-uses-the-same-body-as-test'
|
||||
- depth: 2
|
||||
title: >-
|
||||
Use unsaved node connection details to list the remote inbounds
|
||||
available for selective import.
|
||||
url: >-
|
||||
#use-unsaved-node-connection-details-to-list-the-remote-inbounds-available-for-selective-import
|
||||
title: Use unsaved node connection details to list the remote inbounds available
|
||||
for selective import.
|
||||
url: '#use-unsaved-node-connection-details-to-list-the-remote-inbounds-available-for-selective-import'
|
||||
- depth: 2
|
||||
title: Probe an existing node, updating its cached health state.
|
||||
url: '#probe-an-existing-node-updating-its-cached-health-state'
|
||||
- depth: 2
|
||||
title: >-
|
||||
Trigger the official panel self-updater on each given node (downloads
|
||||
title: 'Trigger the official panel self-updater on each given node (downloads
|
||||
the latest release and restarts). Only enabled, online nodes are
|
||||
updated; offline/disabled ones are reported as skipped. Set "dev": true
|
||||
to move the nodes to the rolling per-commit dev channel instead of the
|
||||
latest stable release. Returns a per-node result list.
|
||||
url: >-
|
||||
#trigger-the-official-panel-self-updater-on-each-given-node-downloads-the-latest-release-and-restarts-only-enabled-online-nodes-are-updated-offlinedisabled-ones-are-reported-as-skipped-set-dev-true-to-move-the-nodes-to-the-rolling-per-commit-dev-channel-instead-of-the-latest-stable-release-returns-a-per-node-result-list
|
||||
latest stable release. Returns a per-node result list.'
|
||||
url: '#trigger-the-official-panel-self-updater-on-each-given-node-downloads-the-latest-release-and-restarts-only-enabled-online-nodes-are-updated-offlinedisabled-ones-are-reported-as-skipped-set-dev-true-to-move-the-nodes-to-the-rolling-per-commit-dev-channel-instead-of-the-latest-stable-release-returns-a-per-node-result-list'
|
||||
- depth: 2
|
||||
title: >-
|
||||
Aggregated metric history for a node — same shape as /server/history,
|
||||
title: Aggregated metric history for a node — same shape as /server/history,
|
||||
scoped to one node.
|
||||
url: >-
|
||||
#aggregated-metric-history-for-a-node--same-shape-as-serverhistory-scoped-to-one-node
|
||||
url: '#aggregated-metric-history-for-a-node--same-shape-as-serverhistory-scoped-to-one-node'
|
||||
- depth: 2
|
||||
title: Validate the stored master mTLS client credential and invalidate cached
|
||||
transports. Each transport closes its old idle pool and rebuilds with
|
||||
the rotated certificate before its next request.
|
||||
url: '#validate-the-stored-master-mtls-client-credential-and-invalidate-cached-transports-each-transport-closes-its-old-idle-pool-and-rebuilds-with-the-rotated-certificate-before-its-next-request'
|
||||
structuredData:
|
||||
headings:
|
||||
- content: >-
|
||||
List every configured node with its connection details, health, and
|
||||
- content: List every configured node with its connection details, health, and
|
||||
last heartbeat patch.
|
||||
id: >-
|
||||
list-every-configured-node-with-its-connection-details-health-and-last-heartbeat-patch
|
||||
- content: >-
|
||||
This panel's node-auth CA certificate (public, PEM) to paste into a
|
||||
id: list-every-configured-node-with-its-connection-details-health-and-last-heartbeat-patch
|
||||
- content: This panel's node-auth CA certificate (public, PEM) to paste into a
|
||||
node's mTLS trust setting. Lazily mints the CA and the master client
|
||||
cert on first call. Pair with setting tlsVerifyMode=mtls on the node.
|
||||
id: >-
|
||||
this-panels-node-auth-ca-certificate-public-pem-to-paste-into-a-nodes-mtls-trust-setting-lazily-mints-the-ca-and-the-master-client-cert-on-first-call-pair-with-setting-tlsverifymodemtls-on-the-node
|
||||
- content: >-
|
||||
Set the CA certificate this panel trusts for incoming node-API client
|
||||
id: this-panels-node-auth-ca-certificate-public-pem-to-paste-into-a-nodes-mtls-trust-setting-lazily-mints-the-ca-and-the-master-client-cert-on-first-call-pair-with-setting-tlsverifymodemtls-on-the-node
|
||||
- content: Set the CA certificate this panel trusts for incoming node-API client
|
||||
certificates (this panel acting as a node). Paste the managing panel's
|
||||
CA (from nodes/mtls/ca). An empty caCert disables it. A non-empty
|
||||
value must be a PEM certificate. Applied on the next panel restart.
|
||||
id: >-
|
||||
set-the-ca-certificate-this-panel-trusts-for-incoming-node-api-client-certificates-this-panel-acting-as-a-node-paste-the-managing-panels-ca-from-nodesmtlsca-an-empty-cacert-disables-it-a-non-empty-value-must-be-a-pem-certificate-applied-on-the-next-panel-restart
|
||||
id: set-the-ca-certificate-this-panel-trusts-for-incoming-node-api-client-certificates-this-panel-acting-as-a-node-paste-the-managing-panels-ca-from-nodesmtlsca-an-empty-cacert-disables-it-a-non-empty-value-must-be-a-pem-certificate-applied-on-the-next-panel-restart
|
||||
- content: Fetch a single node by ID.
|
||||
id: fetch-a-single-node-by-id
|
||||
- content: >-
|
||||
Fetch a node's own web TLS certificate/key file paths (proxied to the
|
||||
- content: Fetch a node's own web TLS certificate/key file paths (proxied to the
|
||||
node). Used by the inbound form's "Set Cert from Panel" so a
|
||||
node-assigned inbound gets paths that exist on the node, not the
|
||||
central panel.
|
||||
id: >-
|
||||
fetch-a-nodes-own-web-tls-certificatekey-file-paths-proxied-to-the-node-used-by-the-inbound-forms-set-cert-from-panel-so-a-node-assigned-inbound-gets-paths-that-exist-on-the-node-not-the-central-panel
|
||||
- content: >-
|
||||
Register a new remote node. Provide its URL, apiToken, and optional
|
||||
id: fetch-a-nodes-own-web-tls-certificatekey-file-paths-proxied-to-the-node-used-by-the-inbound-forms-set-cert-from-panel-so-a-node-assigned-inbound-gets-paths-that-exist-on-the-node-not-the-central-panel
|
||||
- content: Register a new remote node. Provide its URL, apiToken, and optional
|
||||
remark / allowPrivateAddress flag.
|
||||
id: >-
|
||||
register-a-new-remote-node-provide-its-url-apitoken-and-optional-remark--allowprivateaddress-flag
|
||||
id: register-a-new-remote-node-provide-its-url-apitoken-and-optional-remark--allowprivateaddress-flag
|
||||
- content: Replace a node’s connection details. Same body shape as /add.
|
||||
id: replace-a-nodes-connection-details-same-body-shape-as-add
|
||||
- content: Delete a node. Inbounds bound to it are not auto-migrated.
|
||||
id: delete-a-node-inbounds-bound-to-it-are-not-auto-migrated
|
||||
- content: Pause or resume traffic sync with this node.
|
||||
id: pause-or-resume-traffic-sync-with-this-node
|
||||
- content: >-
|
||||
Probe a node without saving it. Uses the body as connection details
|
||||
and returns the same heartbeat snapshot a registered node would have.
|
||||
id: >-
|
||||
probe-a-node-without-saving-it-uses-the-body-as-connection-details-and-returns-the-same-heartbeat-snapshot-a-registered-node-would-have
|
||||
- content: >-
|
||||
Connect to the node over HTTPS without verifying its certificate and
|
||||
- content: Probe a node without saving it. Uses the body as connection details and
|
||||
returns the same heartbeat snapshot a registered node would have.
|
||||
id: probe-a-node-without-saving-it-uses-the-body-as-connection-details-and-returns-the-same-heartbeat-snapshot-a-registered-node-would-have
|
||||
- content: Connect to the node over HTTPS without verifying its certificate and
|
||||
return the leaf certificate's SHA-256 (base64). Used by the Add/Edit
|
||||
Node dialog to fetch and pin a self-signed certificate. Uses the same
|
||||
body as /test.
|
||||
id: >-
|
||||
connect-to-the-node-over-https-without-verifying-its-certificate-and-return-the-leaf-certificates-sha-256-base64-used-by-the-addedit-node-dialog-to-fetch-and-pin-a-self-signed-certificate-uses-the-same-body-as-test
|
||||
- content: >-
|
||||
Use unsaved node connection details to list the remote inbounds
|
||||
id: connect-to-the-node-over-https-without-verifying-its-certificate-and-return-the-leaf-certificates-sha-256-base64-used-by-the-addedit-node-dialog-to-fetch-and-pin-a-self-signed-certificate-uses-the-same-body-as-test
|
||||
- content: Use unsaved node connection details to list the remote inbounds
|
||||
available for selective import.
|
||||
id: >-
|
||||
use-unsaved-node-connection-details-to-list-the-remote-inbounds-available-for-selective-import
|
||||
id: use-unsaved-node-connection-details-to-list-the-remote-inbounds-available-for-selective-import
|
||||
- content: Probe an existing node, updating its cached health state.
|
||||
id: probe-an-existing-node-updating-its-cached-health-state
|
||||
- content: >-
|
||||
Trigger the official panel self-updater on each given node (downloads
|
||||
- content: 'Trigger the official panel self-updater on each given node (downloads
|
||||
the latest release and restarts). Only enabled, online nodes are
|
||||
updated; offline/disabled ones are reported as skipped. Set "dev":
|
||||
true to move the nodes to the rolling per-commit dev channel instead
|
||||
of the latest stable release. Returns a per-node result list.
|
||||
id: >-
|
||||
trigger-the-official-panel-self-updater-on-each-given-node-downloads-the-latest-release-and-restarts-only-enabled-online-nodes-are-updated-offlinedisabled-ones-are-reported-as-skipped-set-dev-true-to-move-the-nodes-to-the-rolling-per-commit-dev-channel-instead-of-the-latest-stable-release-returns-a-per-node-result-list
|
||||
- content: >-
|
||||
Aggregated metric history for a node — same shape as /server/history,
|
||||
of the latest stable release. Returns a per-node result list.'
|
||||
id: trigger-the-official-panel-self-updater-on-each-given-node-downloads-the-latest-release-and-restarts-only-enabled-online-nodes-are-updated-offlinedisabled-ones-are-reported-as-skipped-set-dev-true-to-move-the-nodes-to-the-rolling-per-commit-dev-channel-instead-of-the-latest-stable-release-returns-a-per-node-result-list
|
||||
- content: Aggregated metric history for a node — same shape as /server/history,
|
||||
scoped to one node.
|
||||
id: >-
|
||||
aggregated-metric-history-for-a-node--same-shape-as-serverhistory-scoped-to-one-node
|
||||
id: aggregated-metric-history-for-a-node--same-shape-as-serverhistory-scoped-to-one-node
|
||||
- content: Validate the stored master mTLS client credential and invalidate cached
|
||||
transports. Each transport closes its old idle pool and rebuilds with
|
||||
the rotated certificate before its next request.
|
||||
id: validate-the-stored-master-mtls-client-credential-and-invalidate-cached-transports-each-transport-closes-its-old-idle-pool-and-rebuilds-with-the-rotated-certificate-before-its-next-request
|
||||
contents: []
|
||||
---
|
||||
|
||||
@@ -177,7 +145,7 @@ export default function Layout(props) {
|
||||
return (
|
||||
<>
|
||||
{props.children}
|
||||
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/nodes/list","method":"get"},{"path":"/panel/api/nodes/mtls/ca","method":"post"},{"path":"/panel/api/nodes/mtls/trustCA","method":"post"},{"path":"/panel/api/nodes/get/{id}","method":"get"},{"path":"/panel/api/nodes/webCert/{id}","method":"get"},{"path":"/panel/api/nodes/add","method":"post"},{"path":"/panel/api/nodes/update/{id}","method":"post"},{"path":"/panel/api/nodes/del/{id}","method":"post"},{"path":"/panel/api/nodes/setEnable/{id}","method":"post"},{"path":"/panel/api/nodes/test","method":"post"},{"path":"/panel/api/nodes/certFingerprint","method":"post"},{"path":"/panel/api/nodes/inbounds","method":"post"},{"path":"/panel/api/nodes/probe/{id}","method":"post"},{"path":"/panel/api/nodes/updatePanel","method":"post"},{"path":"/panel/api/nodes/history/{id}/{metric}/{bucket}","method":"get"}]} showTitle />
|
||||
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/nodes/list","method":"get"},{"path":"/panel/api/nodes/mtls/ca","method":"post"},{"path":"/panel/api/nodes/mtls/trustCA","method":"post"},{"path":"/panel/api/nodes/get/{id}","method":"get"},{"path":"/panel/api/nodes/webCert/{id}","method":"get"},{"path":"/panel/api/nodes/add","method":"post"},{"path":"/panel/api/nodes/update/{id}","method":"post"},{"path":"/panel/api/nodes/del/{id}","method":"post"},{"path":"/panel/api/nodes/setEnable/{id}","method":"post"},{"path":"/panel/api/nodes/test","method":"post"},{"path":"/panel/api/nodes/certFingerprint","method":"post"},{"path":"/panel/api/nodes/inbounds","method":"post"},{"path":"/panel/api/nodes/probe/{id}","method":"post"},{"path":"/panel/api/nodes/updatePanel","method":"post"},{"path":"/panel/api/nodes/history/{id}/{metric}/{bucket}","method":"get"},{"path":"/panel/api/nodes/mtls/reloadClient","method":"post"}]} showTitle />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -974,7 +974,8 @@
|
||||
"updateStarted": "بدأ تحديث اللوحة",
|
||||
"updateResult": "تم بدء التحديث على {ok} عقدة، فشل {failed}",
|
||||
"updateNoneEligible": "اختر عقدة واحدة على الأقل متصلة ومفعّلة",
|
||||
"saveMtls": "حفظ mTLS النود"
|
||||
"saveMtls": "حفظ mTLS النود",
|
||||
"reloadMtls": "Reload master mTLS credential"
|
||||
},
|
||||
"tlsVerifyMode": "التحقق من TLS",
|
||||
"tlsVerifyModeHint": "كيف يتحقق اللوحة من شهادة HTTPS الخاصة بالعقدة. التثبيت أو التخطّي مخصّصان للشهادات الموقّعة ذاتيًا (عُقد https فقط).",
|
||||
|
||||
@@ -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).",
|
||||
|
||||
@@ -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).",
|
||||
|
||||
@@ -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).",
|
||||
|
||||
@@ -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).",
|
||||
|
||||
@@ -974,7 +974,8 @@
|
||||
"updateStarted": "パネルの更新を開始しました",
|
||||
"updateResult": "{ok} 個のノードで更新を開始、{failed} 個失敗",
|
||||
"updateNoneEligible": "オンラインで有効なノードを少なくとも1つ選択してください",
|
||||
"saveMtls": "ノード mTLS を保存"
|
||||
"saveMtls": "ノード mTLS を保存",
|
||||
"reloadMtls": "Reload master mTLS credential"
|
||||
},
|
||||
"tlsVerifyMode": "TLS 検証",
|
||||
"tlsVerifyModeHint": "パネルがノードの HTTPS 証明書を検証する方法。ピン留めやスキップは自己署名証明書向け(https ノードのみ)。",
|
||||
|
||||
@@ -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).",
|
||||
|
||||
@@ -974,7 +974,8 @@
|
||||
"updateStarted": "Обновление панели запущено",
|
||||
"updateResult": "Обновление запущено на {ok} узлах, {failed} не удалось",
|
||||
"updateNoneEligible": "Выберите хотя бы один включённый узел в сети",
|
||||
"saveMtls": "Сохранить mTLS узла"
|
||||
"saveMtls": "Сохранить mTLS узла",
|
||||
"reloadMtls": "Reload master mTLS credential"
|
||||
},
|
||||
"tlsVerifyMode": "Проверка TLS",
|
||||
"tlsVerifyModeHint": "Как панель проверяет HTTPS-сертификат узла. Закрепление или Пропуск — для самоподписанных сертификатов (только https-узлы).",
|
||||
|
||||
@@ -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).",
|
||||
|
||||
@@ -974,7 +974,8 @@
|
||||
"updateStarted": "Оновлення панелі розпочато",
|
||||
"updateResult": "Оновлення запущено на {ok} вузлах, {failed} не вдалося",
|
||||
"updateNoneEligible": "Виберіть принаймні один увімкнений вузол у мережі",
|
||||
"saveMtls": "Зберегти mTLS вузла"
|
||||
"saveMtls": "Зберегти mTLS вузла",
|
||||
"reloadMtls": "Reload master mTLS credential"
|
||||
},
|
||||
"tlsVerifyMode": "Перевірка TLS",
|
||||
"tlsVerifyModeHint": "Як панель перевіряє HTTPS-сертифікат вузла. Закріплення або Пропуск — для самопідписаних сертифікатів (лише https-вузли).",
|
||||
|
||||
@@ -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).",
|
||||
|
||||
@@ -974,7 +974,8 @@
|
||||
"updateStarted": "已开始更新面板",
|
||||
"updateResult": "已在 {ok} 个节点上触发更新,{failed} 个失败",
|
||||
"updateNoneEligible": "请至少选择一个在线且已启用的节点",
|
||||
"saveMtls": "保存节点 mTLS"
|
||||
"saveMtls": "保存节点 mTLS",
|
||||
"reloadMtls": "Reload master mTLS credential"
|
||||
},
|
||||
"tlsVerifyMode": "TLS 校验",
|
||||
"tlsVerifyModeHint": "面板如何校验节点的 HTTPS 证书。固定或跳过用于自签名证书(仅 https 节点)。",
|
||||
|
||||
@@ -974,7 +974,8 @@
|
||||
"updateStarted": "已開始更新面板",
|
||||
"updateResult": "已在 {ok} 個節點上觸發更新,{failed} 個失敗",
|
||||
"updateNoneEligible": "請至少選擇一個在線且已啟用的節點",
|
||||
"saveMtls": "儲存節點 mTLS"
|
||||
"saveMtls": "儲存節點 mTLS",
|
||||
"reloadMtls": "Reload master mTLS credential"
|
||||
},
|
||||
"tlsVerifyMode": "TLS 驗證",
|
||||
"tlsVerifyModeHint": "面板如何驗證節點的 HTTPS 憑證。釘選或略過用於自簽憑證(僅 https 節點)。",
|
||||
|
||||
Reference in New Issue
Block a user