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:
@@ -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