mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-16 15:17:14 +00:00
4a8fdceed6
* perf(nodes): reuse one pooled client per node instead of rebuilding it The heartbeat probe asks for a client every 5s per node, and for skip, pin and mtls modes HTTPClientForNode built a client with its own transport each time: every tick paid a full TCP+TLS handshake per node, which is the CPU a 100-node fleet reports. Cache the client per node identity, close the previous one when that identity changes, and raise the idle pool caps above any real fleet size so a node's connection survives to its next tick. * perf(nodes): keep one client per node in the pooled cache Round-1 findings on this PR. The eviction dropped only entries whose key did not start with the current identity, so every proxy variant of that identity stayed for the life of the process. That variant is often a fresh loopback port: withOutboundBridge mints one per call and tears the bridge down on return, so each operator "test node" or remote-inbounds action added a client whose key can never be hit again, and a node switched to verify mode orphaned its old entry by returning before the loop. Replacing that filter with one entry per node bounds the cache at the fleet size, and the verify-mode return now clears the node too. TestHTTPClientForNodeKeepsOneClientPerNode fails without this -- watched red, "2, want 1" -- and pins the verify-mode cleanup on the same cache. * style(nodes): keep the eviction comment inside the two-line cap
665 lines
21 KiB
Go
665 lines
21 KiB
Go
package runtime
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"crypto/tls"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"io"
|
|
"net"
|
|
"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"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
|
|
)
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// Heartbeat and traffic sync ask for a client every few seconds; a rebuilt one
|
|
// owns an empty pool, so each tick paid a fresh TCP+TLS handshake per node.
|
|
func TestHTTPClientForNodeReusesOneConnectionAcrossCalls(t *testing.T) {
|
|
var handshakes atomic.Int32
|
|
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
server.Config.ConnState = func(_ net.Conn, state http.ConnState) {
|
|
if state == http.StateNew {
|
|
handshakes.Add(1)
|
|
}
|
|
}
|
|
server.StartTLS()
|
|
defer server.Close()
|
|
|
|
u, err := url.Parse(server.URL)
|
|
if err != nil {
|
|
t.Fatalf("parse test server url: %v", err)
|
|
}
|
|
port, err := strconv.Atoi(u.Port())
|
|
if err != nil {
|
|
t.Fatalf("test server port: %v", err)
|
|
}
|
|
node := &model.Node{
|
|
Id: 31, Address: u.Hostname(), Port: port, Scheme: "https",
|
|
TlsVerifyMode: "skip", AllowPrivateAddress: true,
|
|
}
|
|
|
|
for tick := range 2 {
|
|
client, err := HTTPClientForNode(node, "")
|
|
if err != nil {
|
|
t.Fatalf("tick %d: HTTPClientForNode: %v", tick, err)
|
|
}
|
|
req, err := http.NewRequestWithContext(
|
|
netsafe.ContextWithAllowPrivate(context.Background(), true), http.MethodGet, server.URL, nil)
|
|
if err != nil {
|
|
t.Fatalf("tick %d: new request: %v", tick, err)
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("tick %d: request: %v", tick, err)
|
|
}
|
|
_, _ = io.Copy(io.Discard, resp.Body)
|
|
_ = resp.Body.Close()
|
|
}
|
|
|
|
if got := handshakes.Load(); got != 1 {
|
|
t.Fatalf("TLS handshakes = %d, want 1: a rebuilt client re-handshakes on every tick", got)
|
|
}
|
|
}
|
|
|
|
// A node that switches to pinning (or gains a proxy) must not be served by the
|
|
// client built for its previous trust decision.
|
|
func TestHTTPClientForNodeRebuildsWhenNodeIdentityChanges(t *testing.T) {
|
|
pin := base64.StdEncoding.EncodeToString(make([]byte, sha256.Size))
|
|
node := &model.Node{Id: 32, Address: "node.example.test", Port: 443, Scheme: "https", TlsVerifyMode: "skip"}
|
|
skipped, err := HTTPClientForNode(node, "")
|
|
if err != nil {
|
|
t.Fatalf("skip client: %v", err)
|
|
}
|
|
|
|
pinned := *node
|
|
pinned.TlsVerifyMode = "pin"
|
|
pinned.PinnedCertSha256 = pin
|
|
pinnedClient, err := HTTPClientForNode(&pinned, "")
|
|
if err != nil {
|
|
t.Fatalf("pin client: %v", err)
|
|
}
|
|
if skipped == pinnedClient {
|
|
t.Fatal("a pinned node must not reuse the client built to skip verification")
|
|
}
|
|
|
|
proxied, err := HTTPClientForNode(node, "socks5://127.0.0.1:1080")
|
|
if err != nil {
|
|
t.Fatalf("proxied client: %v", err)
|
|
}
|
|
if skipped == proxied {
|
|
t.Fatal("a proxied node must not reuse the direct client")
|
|
}
|
|
|
|
if again, err := HTTPClientForNode(node, ""); err != nil || again == pinnedClient {
|
|
t.Fatalf("a skip request must never be served the pinned client; again=%p err=%v", again, err)
|
|
}
|
|
}
|
|
|
|
func nodeClientEntries(id int) int {
|
|
nodeClientsMu.Lock()
|
|
defer nodeClientsMu.Unlock()
|
|
count := 0
|
|
for _, entry := range nodeClientsCache {
|
|
if entry.nodeID == id {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
// withOutboundBridge mints a fresh loopback port per call, so the variant it
|
|
// asks for can never be hit again; only the variant in use may stay cached.
|
|
func TestHTTPClientForNodeKeepsOneClientPerNode(t *testing.T) {
|
|
node := &model.Node{Id: 77, Address: "node.example.test", Port: 443, Scheme: "https", TlsVerifyMode: "skip"}
|
|
variants := []string{"socks5://127.0.0.1:41001", "socks5://127.0.0.1:41002", ""}
|
|
for _, variant := range variants {
|
|
if _, err := HTTPClientForNode(node, variant); err != nil {
|
|
t.Fatalf("HTTPClientForNode(%q): %v", variant, err)
|
|
}
|
|
if got := nodeClientEntries(node.Id); got != 1 {
|
|
t.Fatalf("cached clients for the node after %q = %d, want 1", variant, got)
|
|
}
|
|
if client, err := HTTPClientForNode(node, variant); err != nil || client == nil {
|
|
t.Fatalf("repeat HTTPClientForNode(%q): client=%p err=%v", variant, client, err)
|
|
}
|
|
}
|
|
|
|
verify := *node
|
|
verify.TlsVerifyMode = "verify"
|
|
if client, err := HTTPClientForNode(&verify, ""); err != nil || client != defaultNodeHTTPClient {
|
|
t.Fatalf("verify client = %p, want the shared one (%p); err=%v", client, defaultNodeHTTPClient, err)
|
|
}
|
|
if got := nodeClientEntries(node.Id); got != 0 {
|
|
t.Fatalf("cached clients for a node now on verify = %d, want 0", 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)
|
|
}
|
|
})
|
|
}
|
|
}
|