| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537 |
- package runtime
- import (
- "context"
- "crypto/sha256"
- "crypto/tls"
- "encoding/base64"
- "encoding/hex"
- "net/http"
- "net/http/httptest"
- "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()
- ca, err := crypto.GenerateNodeCA("test ca")
- if err != nil {
- t.Fatalf("GenerateNodeCA: %v", err)
- }
- client, err := crypto.IssueClientCert(ca, "master")
- if err != nil {
- t.Fatalf("IssueClientCert: %v", err)
- }
- cert, err := tls.X509KeyPair(client.CertPEM, client.KeyPEM)
- if err != nil {
- t.Fatalf("X509KeyPair: %v", err)
- }
- return cert
- }
- // TestTLSConfigForNode_MTLS_PresentsClientCert asserts the mtls branch presents
- // the master client cert and verifies the node's server cert against system
- // roots (no InsecureSkipVerify, no custom RootCAs).
- func TestTLSConfigForNode_MTLS_PresentsClientCert(t *testing.T) {
- cert := masterCertForTest(t)
- SetMasterClientCertProvider(func() (tls.Certificate, error) { return cert, nil })
- t.Cleanup(func() { SetMasterClientCertProvider(nil) })
- cfg, err := tlsConfigForNode(&model.Node{TlsVerifyMode: "mtls"})
- if err != nil {
- t.Fatalf("tlsConfigForNode(mtls): %v", err)
- }
- if len(cfg.Certificates) != 1 {
- t.Fatalf("mtls config must present exactly one client certificate, got %d", len(cfg.Certificates))
- }
- if cfg.InsecureSkipVerify {
- t.Fatal("mtls must NOT skip server verification")
- }
- if cfg.RootCAs != nil {
- t.Fatal("mtls verifies the node server against system roots (RootCAs must be nil)")
- }
- }
- // TestTLSConfigForNode_MTLS_NoProviderFailsClosed asserts mtls fails closed when
- // no master client certificate is available, rather than silently dropping auth.
- func TestTLSConfigForNode_MTLS_NoProviderFailsClosed(t *testing.T) {
- SetMasterClientCertProvider(nil)
- if _, err := tlsConfigForNode(&model.Node{TlsVerifyMode: "mtls"}); err == nil {
- t.Fatal("mtls without a configured client cert provider must fail closed")
- }
- }
- // nodeForServer builds a node pointing at a loopback test server (loopback is
- // SSRF-blocked, so AllowPrivateAddress is set for the guarded dialer).
- func nodeForServer(t *testing.T, srv *httptest.Server, mode, pin string) *model.Node {
- t.Helper()
- u, err := url.Parse(srv.URL)
- if err != nil {
- t.Fatalf("parse server url: %v", err)
- }
- port, err := strconv.Atoi(u.Port())
- if err != nil {
- t.Fatalf("parse server port: %v", err)
- }
- return &model.Node{
- Id: 1,
- Name: "n1",
- Scheme: "https",
- Address: u.Hostname(),
- Port: port,
- BasePath: "/",
- ApiToken: "token",
- Enable: true,
- AllowPrivateAddress: true,
- TlsVerifyMode: mode,
- PinnedCertSha256: pin,
- }
- }
- func leafPinBase64(srv *httptest.Server) string {
- sum := sha256.Sum256(srv.Certificate().Raw)
- return base64.StdEncoding.EncodeToString(sum[:])
- }
- // A self-signed node must be reachable by Remote ops under skip/pin and
- // rejected under verify — the split issue #5264 reported.
- func TestRemoteHonorsTLSVerifyMode(t *testing.T) {
- srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(`{"success":true,"obj":[]}`))
- }))
- defer srv.Close()
- goodPin := leafPinBase64(srv)
- wrongPin := base64.StdEncoding.EncodeToString(make([]byte, sha256.Size))
- cases := []struct {
- name string
- mode string
- pin string
- wantErr bool
- }{
- {"verify rejects self-signed", "verify", "", true},
- {"skip accepts self-signed", "skip", "", false},
- {"pin accepts matching cert", "pin", goodPin, false},
- {"pin rejects mismatched cert", "pin", wrongPin, true},
- }
- for _, c := range cases {
- t.Run(c.name, func(t *testing.T) {
- r := NewRemote(nodeForServer(t, srv, c.mode, c.pin), nil)
- _, err := r.ListInboundOptions(context.Background())
- if c.wantErr && err == nil {
- t.Fatalf("mode %q: expected error, got nil", c.mode)
- }
- if !c.wantErr && err != nil {
- t.Fatalf("mode %q: unexpected error: %v", c.mode, err)
- }
- })
- }
- }
- // The lazily-built client is cached for the Remote's lifetime so repeated
- // operations reuse one pooled transport rather than rebuilding TLS each call.
- func TestRemoteClientCached(t *testing.T) {
- r := NewRemote(&model.Node{Scheme: "https", TlsVerifyMode: "skip"}, nil)
- c1, err1 := r.httpClient()
- c2, err2 := r.httpClient()
- if err1 != nil || err2 != nil {
- t.Fatalf("httpClient errors: %v %v", err1, err2)
- }
- if c1 != c2 {
- t.Fatal("expected the same cached client across calls")
- }
- }
- func TestHTTPClientForNodeVerifyShared(t *testing.T) {
- // verify mode and plain http both reuse the shared default client.
- for _, n := range []*model.Node{
- {Scheme: "https", TlsVerifyMode: "verify"},
- {Scheme: "https", TlsVerifyMode: ""},
- {Scheme: "http", TlsVerifyMode: "skip"},
- } {
- c, err := HTTPClientForNode(n, "")
- if err != nil {
- t.Fatalf("HTTPClientForNode(%+v): %v", n, err)
- }
- if c != defaultNodeHTTPClient {
- t.Fatalf("HTTPClientForNode(%+v) = %p, want shared default %p", n, c, defaultNodeHTTPClient)
- }
- }
- }
- func TestHTTPClientForNodePinInvalid(t *testing.T) {
- // pin mode must fail closed, and with a specific error per cause — not merely
- // "some error" (which a bug anywhere in the build path would also satisfy).
- cases := []struct {
- name string
- pin string
- wantErr string
- }{
- {"garbage pin", "not-a-pin", "must be a SHA-256 hash"},
- {"empty pin", "", "certificate pin is empty"},
- }
- for _, c := range cases {
- t.Run(c.name, func(t *testing.T) {
- _, err := HTTPClientForNode(&model.Node{Scheme: "https", TlsVerifyMode: "pin", PinnedCertSha256: c.pin}, "")
- if err == nil {
- t.Fatalf("expected error for pin %q", c.pin)
- }
- if !strings.Contains(err.Error(), c.wantErr) {
- t.Fatalf("error = %q, want it to contain %q", err.Error(), c.wantErr)
- }
- })
- }
- }
- // TestHTTPClientForNode_ProxyPinPreservesPinEnforcement covers the proxy+pin branch
- // (tls_client.go:43-52): when a node uses a proxy AND pin mode, the proxy client's
- // transport must carry the pinning tls.Config (the `transport.TLSClientConfig = tlsCfg`
- // line). Dropping it would silently disable certificate pinning whenever a proxy is set.
- func TestHTTPClientForNode_ProxyPinPreservesPinEnforcement(t *testing.T) {
- pin := base64.StdEncoding.EncodeToString(make([]byte, sha256.Size))
- n := &model.Node{Scheme: "https", TlsVerifyMode: "pin", PinnedCertSha256: pin}
- c, err := HTTPClientForNode(n, "socks5://127.0.0.1:1080")
- if err != nil {
- t.Fatalf("HTTPClientForNode: %v", err)
- }
- if c == defaultNodeHTTPClient {
- t.Fatal("proxy client must not be the shared default client")
- }
- tr, ok := c.Transport.(*http.Transport)
- if !ok {
- t.Fatalf("transport is %T, want *http.Transport", c.Transport)
- }
- if tr.TLSClientConfig == nil || tr.TLSClientConfig.VerifyConnection == nil {
- t.Fatal("pin mode over a proxy must install a pinning tls.Config (VerifyConnection); pin enforcement was dropped")
- }
- }
- // TestHTTPClientForNode_ProxyVerifyNoPin covers the proxy+verify branch
- // (tls_client.go:40-42): verify mode over a proxy returns the proxy client as-is,
- // using system-CA verification and NOT a pin VerifyConnection.
- func TestHTTPClientForNode_ProxyVerifyNoPin(t *testing.T) {
- n := &model.Node{Scheme: "https", TlsVerifyMode: "verify"}
- c, err := HTTPClientForNode(n, "socks5://127.0.0.1:1080")
- if err != nil {
- t.Fatalf("HTTPClientForNode: %v", err)
- }
- if c == defaultNodeHTTPClient {
- t.Fatal("proxy client must not be the shared default client")
- }
- if tr, ok := c.Transport.(*http.Transport); ok && tr.TLSClientConfig != nil && tr.TLSClientConfig.VerifyConnection != nil {
- t.Fatal("verify mode must not install a pin VerifyConnection")
- }
- }
- // TestTLSConfigForNode_CurrentContract locks the pre-mTLS behavior of
- // tlsConfigForNode so the "mtls" branch added later cannot silently regress the
- // existing skip/pin modes (characterization — passes on unchanged code).
- func TestTLSConfigForNode_CurrentContract(t *testing.T) {
- t.Run("skip disables verification with no VerifyConnection", func(t *testing.T) {
- cfg, err := tlsConfigForNode(&model.Node{TlsVerifyMode: "skip"})
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- if !cfg.InsecureSkipVerify {
- t.Fatal("skip mode must set InsecureSkipVerify")
- }
- if cfg.VerifyConnection != nil {
- t.Fatal("skip mode must not install a VerifyConnection")
- }
- })
- t.Run("pin installs a VerifyConnection", func(t *testing.T) {
- pin := base64.StdEncoding.EncodeToString(make([]byte, sha256.Size))
- cfg, err := tlsConfigForNode(&model.Node{TlsVerifyMode: "pin", PinnedCertSha256: pin})
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- if cfg.VerifyConnection == nil {
- t.Fatal("pin mode must install a VerifyConnection")
- }
- })
- }
- func TestDecodeCertPin(t *testing.T) {
- raw := sha256.Sum256([]byte("cert"))
- hexColon := strings.ToUpper(hex.EncodeToString(raw[:]))
- // reinsert colons in openssl -fingerprint style
- var withColons strings.Builder
- for i := 0; i < len(hexColon); i += 2 {
- if i > 0 {
- withColons.WriteByte(':')
- }
- withColons.WriteString(hexColon[i : i+2])
- }
- cases := []struct {
- name string
- in string
- wantErr bool
- }{
- {"base64 std", base64.StdEncoding.EncodeToString(raw[:]), false},
- {"base64 raw url", base64.RawURLEncoding.EncodeToString(raw[:]), false},
- {"hex bare", hex.EncodeToString(raw[:]), false},
- {"hex colon openssl", withColons.String(), false},
- {"empty", "", true},
- {"garbage", "not-a-pin", true},
- }
- for _, c := range cases {
- t.Run(c.name, func(t *testing.T) {
- got, err := DecodeCertPin(c.in)
- if c.wantErr {
- if err == nil {
- t.Fatalf("expected error for %q", c.in)
- }
- return
- }
- if err != nil {
- t.Fatalf("unexpected error for %q: %v", c.in, err)
- }
- if string(got) != string(raw[:]) {
- t.Fatalf("decoded bytes mismatch for %q", c.in)
- }
- })
- }
- }
|