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
+18
View File
@@ -1,11 +1,13 @@
package service
import (
"crypto/tls"
"crypto/x509"
"encoding/pem"
"strings"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
)
// NodeMtlsCaCert returns the PEM of this panel's node-auth CA certificate (the
@@ -24,6 +26,22 @@ func (s *NodeService) NodeMtlsCaCert() (string, error) {
return string(ca.CertPEM), nil
}
// ReloadMasterMtlsClient validates the master credential currently stored by
// the panel and drops cached mTLS connection pools. This makes an intentional
// out-of-process credential rotation take effect without restarting x-ui (and
// therefore without stopping the xray child process in the same service).
func (s *NodeService) ReloadMasterMtlsClient() error {
stored, err := (&SettingService{}).LoadMasterClientCert()
if err != nil {
return err
}
if _, err := tls.X509KeyPair(stored.CertPEM, stored.KeyPEM); err != nil {
return err
}
runtime.InvalidateMasterClientConnections()
return nil
}
// SetNodeMtlsTrustCA stores the CA certificate this panel trusts for incoming
// node-API client certificates. An empty value clears it (mTLS off). A
// non-empty value must be a PEM certificate (fail closed). Takes effect on the
+26
View File
@@ -1,15 +1,41 @@
package service
import (
"crypto/tls"
"crypto/x509"
"encoding/pem"
"testing"
"github.com/go-playground/validator/v10"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
)
func TestReloadMasterMtlsClientDoesNotMintMissingCredential(t *testing.T) {
_ = setupSettingMtlsDB(t)
runtime.SetMasterClientCertProvider(func() (tls.Certificate, error) {
pair, err := (&SettingService{}).EnsureMasterClientCert()
if err != nil {
return tls.Certificate{}, err
}
return tls.X509KeyPair(pair.CertPEM, pair.KeyPEM)
})
t.Cleanup(func() { runtime.SetMasterClientCertProvider(nil) })
if err := (&NodeService{}).ReloadMasterMtlsClient(); err == nil {
t.Fatal("reload on a fresh database unexpectedly succeeded")
}
var count int64
keys := []string{settingNodeMtlsCaCert, settingNodeMtlsCaKey, settingNodeMtlsClientCert, settingNodeMtlsClientKey}
if err := database.GetDB().Model(&model.Setting{}).Where("key IN ?", keys).Count(&count).Error; err != nil {
t.Fatalf("count mTLS settings: %v", err)
}
if count != 0 {
t.Fatalf("reload created %d mTLS setting rows, want 0", count)
}
}
func TestNormalizeKeepsMtls(t *testing.T) {
s := &NodeService{}
cases := []struct {
+19
View File
@@ -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