From 437bdaa51cc6e4ba69df851e7a1cd0f7fa4eafb0 Mon Sep 17 00:00:00 2001 From: Manish Tiwary Date: Fri, 30 Jan 2026 15:21:11 +0000 Subject: [PATCH 1/6] cli: implement round-robin DNS IP rotation This change introduces deterministic IP-level load distribution at the transport layer. Previously, Warp relied on the OS or a one-time resolution, which could lead to uneven traffic distribution when using a single hostname sitting in front of multiple gateways/IPs Key improvements: - Added a thread-safe DNS cache (sync.Map) and atomic counter to rotate between IPs for every new connection. - Fixed TLS SNI verification: when dialing a resolved IP, the original hostname is now explicitly set in TLSClientConfig.ServerName to prevent certificate hostname mismatches. - Applied rotation logic to both standard TLS and kTLS transport paths. - Optimized performance by reducing redundant DNS lookups during high-concurrency benchmarks. --- cli/client_ktls.go | 2 ++ cli/client_transport.go | 15 ++++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/cli/client_ktls.go b/cli/client_ktls.go index 4778223e..bb4dad63 100644 --- a/cli/client_ktls.go +++ b/cli/client_ktls.go @@ -18,6 +18,8 @@ package cli import ( + "context" + "net" stdHttp "net/http" "os" "time" diff --git a/cli/client_transport.go b/cli/client_transport.go index f07b312f..8dbef181 100644 --- a/cli/client_transport.go +++ b/cli/client_transport.go @@ -22,6 +22,8 @@ import ( "crypto/tls" "net" "net/http" + "sync" + "sync/atomic" "time" "github.com/minio/cli" @@ -72,7 +74,6 @@ func withDialTLSContext(dialer func(ctx context.Context, network, addr string) ( func newClientTransport(ctx *cli.Context, options ...transportOption) http.RoundTripper { tr := &http.Transport{ Proxy: http.ProxyFromEnvironment, - DialContext: netDialer.DialContext, MaxIdleConnsPerHost: ctx.Int("concurrent"), WriteBufferSize: ctx.Int("sndbuf"), // Configure beyond 4KiB default buffer size. ReadBufferSize: ctx.Int("rcvbuf"), // Configure beyond 4KiB default buffer size. @@ -93,6 +94,18 @@ func newClientTransport(ctx *cli.Context, options ...transportOption) http.Round ForceAttemptHTTP2: ctx.Bool("http2"), } + tr.DialContext = func(dialCtx context.Context, network, addr string) (net.Conn, error) { + newAddr, host, _ := resolveAndRotate(dialCtx, addr) + + // Ensure SNI is set to the original host so TLS verification passes + // when connecting via IP address. + if tr.TLSClientConfig != nil && tr.TLSClientConfig.ServerName == "" { + tr.TLSClientConfig.ServerName = host + } + + return netDialer.DialContext(dialCtx, network, newAddr) + } + for _, option := range options { option(tr) } From 934ec357f33d479ee5efe1f4d9d4644ea465cd98 Mon Sep 17 00:00:00 2001 From: Manish Tiwary Date: Thu, 5 Feb 2026 05:40:30 +0000 Subject: [PATCH 2/6] Fix Virtual-Host routing and SNI verification for direct-IP benchmarking when using --resolve-host flag --- cli/client.go | 50 ++++++++++++++++++++++++++------------ cli/client_ktls.go | 5 ++++ cli/client_tls.go | 2 ++ cli/client_transport.go | 33 +++++++++++++------------ cli/flags.go | 8 +++++- pkg/bench/benchmark.go | 2 +- pkg/bench/multipart.go | 6 ++--- pkg/bench/multipart_put.go | 6 ++--- pkg/bench/ops.go | 15 ++++++++++++ pkg/bench/put.go | 2 +- 10 files changed, 89 insertions(+), 40 deletions(-) diff --git a/cli/client.go b/cli/client.go index fd65e9b1..2860a01f 100644 --- a/cli/client.go +++ b/cli/client.go @@ -26,6 +26,7 @@ import ( "math/rand" "net" "net/http" + "net/url" "os" "strings" "sync" @@ -51,16 +52,18 @@ const ( hostSelectTypeWeighed hostSelectType = "weighed" ) -func newClient(ctx *cli.Context) func() (cl *minio.Client, done func()) { - hosts := parseHosts(ctx.String("host"), ctx.Bool("resolve-host")) +func newClient(ctx *cli.Context) func() (cl *bench.Client, done func()) { + rawHost := ctx.String("host") + hosts := parseHosts(rawHost, ctx.Bool("resolve-host")) + switch len(hosts) { case 0: fatalIf(probe.NewError(errors.New("no host defined")), "Unable to create MinIO client") case 1: - cl, err := getClient(ctx, hosts[0]) + cl, err := getClient(ctx, hosts[0], rawHost) fatalIf(probe.NewError(err), "Unable to create MinIO client") - return func() (*minio.Client, func()) { + return func() (*bench.Client, func()) { return cl, func() {} } } @@ -70,13 +73,13 @@ func newClient(ctx *cli.Context) func() (cl *minio.Client, done func()) { // Do round-robin. var current int var mu sync.Mutex - clients := make([]*minio.Client, len(hosts)) + clients := make([]*bench.Client, len(hosts)) for i := range hosts { - cl, err := getClient(ctx, hosts[i]) + cl, err := getClient(ctx, hosts[i], rawHost) fatalIf(probe.NewError(err), "Unable to create MinIO client") clients[i] = cl } - return func() (*minio.Client, func()) { + return func() (*bench.Client, func()) { mu.Lock() now := current % len(clients) current++ @@ -87,9 +90,9 @@ func newClient(ctx *cli.Context) func() (cl *minio.Client, done func()) { // Keep track of handed out clients. // Select random between the clients that have the fewest handed out. var mu sync.Mutex - clients := make([]*minio.Client, len(hosts)) + clients := make([]*bench.Client, len(hosts)) for i := range hosts { - cl, err := getClient(ctx, hosts[i]) + cl, err := getClient(ctx, hosts[i], rawHost) fatalIf(probe.NewError(err), "Unable to create MinIO client") clients[i] = cl } @@ -122,7 +125,7 @@ func newClient(ctx *cli.Context) func() (cl *minio.Client, done func()) { } return earliestIdx } - return func() (*minio.Client, func()) { + return func() (*bench.Client, func()) { mu.Lock() idx := find() running[idx]++ @@ -159,7 +162,21 @@ func detectLocalIP(host string) string { } // getClient creates a client with the specified host and the options set in the context. -func getClient(ctx *cli.Context, host string) (*minio.Client, error) { +func getClient( + ctx *cli.Context, + host string, + originalHost string, +) (*bench.Client, error) { + u, _ := url.Parse(originalHost) + if u == nil || u.Host == "" { + scheme := "http" + if ctx.Bool("tls") || ctx.Bool("ktls") { + scheme = "https" + } + u, _ = url.Parse(fmt.Sprintf("%s://%s", scheme, originalHost)) + } + domainName := u.Host + transport := clientTransport(ctx, host) var creds *credentials.Credentials localIP := clientListenIP if localIP == "" { @@ -189,7 +206,7 @@ func getClient(ctx *cli.Context, host string) (*minio.Client, error) { if ctx.Bool("tls") || ctx.Bool("ktls") { proto = "https" } - stsEndPoint := fmt.Sprintf("%s://%s", proto, host) + stsEndPoint := fmt.Sprintf("%s://%s", proto, domainName) creds, err = credentials.NewSTSWebIdentity(stsEndPoint, func() (*credentials.WebIdentityToken, error) { stsToken := ctx.String("sts-web-token") if stsTokenFile, hasFilePrefix := strings.CutPrefix(stsToken, "file:"); hasFilePrefix { @@ -214,7 +231,7 @@ func getClient(ctx *cli.Context, host string) (*minio.Client, error) { } else if ctx.String("lookup") == "path" { lookup = minio.BucketLookupPath } - cl, err := minio.New(host, &minio.Options{ + cl, err := minio.New(domainName, &minio.Options{ Creds: creds, Secure: ctx.Bool("tls") || ctx.Bool("ktls"), Region: ctx.String("region"), @@ -232,7 +249,10 @@ func getClient(ctx *cli.Context, host string) (*minio.Client, error) { cl.TraceOn(os.Stderr) } - return cl, nil + return &bench.Client{ + Client: cl, + Host: u, + }, nil } func clientTransport(ctx *cli.Context) http.RoundTripper { @@ -346,7 +366,7 @@ func newAdminClient(ctx *cli.Context) *madmin.AdminClient { cl, err := madmin.NewWithOptions(hosts[0], &madmin.Options{ Creds: credentials.NewStaticV4(ctx.String("access-key"), ctx.String("secret-key"), ""), Secure: ctx.Bool("tls") || ctx.Bool("ktls"), - Transport: clientTransport(ctx), + Transport: clientTransport(ctx, hosts[0]), }) fatalIf(probe.NewError(err), "Unable to create MinIO admin client") cl.SetAppInfo(appName, pkg.Version) diff --git a/cli/client_ktls.go b/cli/client_ktls.go index bb4dad63..0dcfc64f 100644 --- a/cli/client_ktls.go +++ b/cli/client_ktls.go @@ -21,6 +21,7 @@ import ( "context" "net" stdHttp "net/http" + "net/url" "os" "time" @@ -31,6 +32,9 @@ import ( func clientTransportKTLS(ctx *cli.Context, localIP string) stdHttp.RoundTripper { // Keep TLS config. + rawHost := ctx.String("host") + u, _ := url.Parse("https://" + rawHost) + sni := u.Hostname() tlsConfig := &tls.Config{ RootCAs: mustGetSystemCertPool(), // Can't use SSLv3 because of POODLE and BEAST @@ -38,6 +42,7 @@ func clientTransportKTLS(ctx *cli.Context, localIP string) stdHttp.RoundTripper // Can't use TLSv1.1 because of RC4 cipher usage MinVersion: tls.VersionTLS12, InsecureSkipVerify: ctx.Bool("insecure"), + ServerName: sni, ClientSessionCache: tls.NewLRUClientSessionCache(1024), // up to 1024 nodes // Extra configs diff --git a/cli/client_tls.go b/cli/client_tls.go index 98a6f31b..e2471b42 100644 --- a/cli/client_tls.go +++ b/cli/client_tls.go @@ -20,6 +20,7 @@ package cli import ( "crypto/tls" "net/http" + "net/url" "os" "github.com/minio/cli" @@ -34,6 +35,7 @@ func clientTransportTLS(ctx *cli.Context, localIP string) http.RoundTripper { // Can't use TLSv1.1 because of RC4 cipher usage MinVersion: tls.VersionTLS12, InsecureSkipVerify: ctx.Bool("insecure"), + ServerName: sni, ClientSessionCache: tls.NewLRUClientSessionCache(1024), // up to 1024 nodes } diff --git a/cli/client_transport.go b/cli/client_transport.go index 8dbef181..73b3638a 100644 --- a/cli/client_transport.go +++ b/cli/client_transport.go @@ -22,8 +22,6 @@ import ( "crypto/tls" "net" "net/http" - "sync" - "sync/atomic" "time" "github.com/minio/cli" @@ -71,9 +69,24 @@ func withDialTLSContext(dialer func(ctx context.Context, network, addr string) ( } } -func newClientTransport(ctx *cli.Context, options ...transportOption) http.RoundTripper { +func newClientTransport(ctx *cli.Context, targetIP string, options ...transportOption) http.RoundTripper { + isTLS := ctx.Bool("tls") || ctx.Bool("ktls") tr := &http.Transport{ - Proxy: http.ProxyFromEnvironment, + Proxy: http.ProxyFromEnvironment, + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + // Extract the port from the original address (e.g., "s3.boston...:443") + _, port, err := net.SplitHostPort(addr) + if err != nil { + if isTLS { + port = "443" + } else { + port = "80" + } + } + + dialAddr := net.JoinHostPort(targetIP, port) + return netDialer.DialContext(ctx, network, dialAddr) + }, MaxIdleConnsPerHost: ctx.Int("concurrent"), WriteBufferSize: ctx.Int("sndbuf"), // Configure beyond 4KiB default buffer size. ReadBufferSize: ctx.Int("rcvbuf"), // Configure beyond 4KiB default buffer size. @@ -94,18 +107,6 @@ func newClientTransport(ctx *cli.Context, options ...transportOption) http.Round ForceAttemptHTTP2: ctx.Bool("http2"), } - tr.DialContext = func(dialCtx context.Context, network, addr string) (net.Conn, error) { - newAddr, host, _ := resolveAndRotate(dialCtx, addr) - - // Ensure SNI is set to the original host so TLS verification passes - // when connecting via IP address. - if tr.TLSClientConfig != nil && tr.TLSClientConfig.ServerName == "" { - tr.TLSClientConfig.ServerName = host - } - - return netDialer.DialContext(dialCtx, network, newAddr) - } - for _, option := range options { option(tr) } diff --git a/cli/flags.go b/cli/flags.go index 04a0b4fd..a06d0c89 100644 --- a/cli/flags.go +++ b/cli/flags.go @@ -18,6 +18,7 @@ package cli import ( + "errors" "fmt" "os" "strings" @@ -346,6 +347,11 @@ func getCommon(ctx *cli.Context, src func() generator.Source) bench.Common { // set burst to 1 as limiter will always be called to wait for 1 token rpsLimiter = rate.NewLimiter(rate.Limit(rpsLimit), 1) } + // Parse hosts to get a target IP for the transport signature + hosts := parseHosts(ctx.String("host"), ctx.Bool("resolve-host")) + if len(hosts) == 0 { + fatalIf(probe.NewError(errors.New("no host defined")), "Unable to initialize transport") + } // Create put options now, so ensure that trailing headers are set. putOpts := putOpts(ctx) return bench.Common{ @@ -358,7 +364,7 @@ func getCommon(ctx *cli.Context, src func() generator.Source) bench.Common { DiscardOutput: noOps, ExtraOut: extra, RpsLimiter: rpsLimiter, - Transport: clientTransport(ctx), + Transport: clientTransport(ctx, hosts[0]), UpdateStatus: statusln, TotalClients: 1, // Default to 1 for single-client mode } diff --git a/pkg/bench/benchmark.go b/pkg/bench/benchmark.go index 8aa29892..07bcb141 100644 --- a/pkg/bench/benchmark.go +++ b/pkg/bench/benchmark.go @@ -70,7 +70,7 @@ type Common struct { // Error should log an error similar to fmt.Print(data...) Error func(data ...any) - Client func() (cl *minio.Client, done func()) + Client func() (cl *Client, done func()) Collector Collector diff --git a/pkg/bench/multipart.go b/pkg/bench/multipart.go index ee9de016..1ba16421 100644 --- a/pkg/bench/multipart.go +++ b/pkg/bench/multipart.go @@ -54,7 +54,7 @@ func (g *Multipart) InitOnce(ctx context.Context) error { g.UpdateStatus("Creating Object...") cl, done := g.Client() - c := minio.Core{Client: cl} + c := minio.Core{Client: cl.Client} defer done() uploadID, err := c.NewMultipartUpload(ctx, g.Bucket, g.ObjName, g.PutOpts) if err != nil { @@ -109,7 +109,7 @@ func (g *Multipart) Prepare(ctx context.Context) error { obj := src.Object() obj.Name = name client, cldone := g.Client() - core := minio.Core{Client: client} + core := minio.Core{Client: client.Client} op := Operation{ OpType: http.MethodPut, Thread: uint32(i), @@ -168,7 +168,7 @@ func (g *Multipart) Prepare(ctx context.Context) error { func (g *Multipart) AfterPrepare(ctx context.Context) error { cl, done := g.Client() - c := minio.Core{Client: cl} + c := minio.Core{Client: cl.Client} defer done() var parts []minio.CompletePart i := 1 diff --git a/pkg/bench/multipart_put.go b/pkg/bench/multipart_put.go index 89ac5e10..148c51c7 100644 --- a/pkg/bench/multipart_put.go +++ b/pkg/bench/multipart_put.go @@ -85,7 +85,7 @@ func (g *MultipartPut) createMultupartUpload(ctx context.Context, objectName str client, done := g.Client() defer done() - c := minio.Core{Client: client} + c := minio.Core{Client: client.Client} return c.NewMultipartUpload(nonTerm, g.Bucket, objectName, g.PutOpts) } @@ -125,7 +125,7 @@ func (g *MultipartPut) uploadParts(ctx context.Context, thread uint32, objectNam obj := g.Source().Object() client, done := g.Client() defer done() - core := minio.Core{Client: client} + core := minio.Core{Client: client.Client} op := Operation{ OpType: "PUTPART", Thread: thread*uint32(g.PartsConcurrency) + uint32(i), @@ -184,7 +184,7 @@ func (g *MultipartPut) completeMultipartUpload(ctx context.Context, objectName, nonTerm := context.Background() cl, done := g.Client() - c := minio.Core{Client: cl} + c := minio.Core{Client: cl.Client} defer done() _, err := c.CompleteMultipartUpload(nonTerm, g.Bucket, objectName, uploadID, parts, g.PutOpts) return err diff --git a/pkg/bench/ops.go b/pkg/bench/ops.go index a6c1f691..45e848a5 100644 --- a/pkg/bench/ops.go +++ b/pkg/bench/ops.go @@ -23,6 +23,7 @@ import ( "fmt" "io" "math" + "net/url" "sort" "strconv" "strings" @@ -30,6 +31,7 @@ import ( "time" "github.com/dustin/go-humanize" + "github.com/minio/minio-go/v7" ) type Operations []Operation @@ -1230,3 +1232,16 @@ func StreamOperationsFromCSV(r io.Reader, analyzeOnly bool, offset, limit int, l } return nil } + +type Client struct { + *minio.Client + Host *url.URL +} + +// EndpointURL returns the endpoint URL. +func (c *Client) EndpointURL() *url.URL { + if c.Host != nil { + return c.Host + } + return c.Client.EndpointURL() +} diff --git a/pkg/bench/put.go b/pkg/bench/put.go index 7bdc1fc9..50c0c3fb 100644 --- a/pkg/bench/put.go +++ b/pkg/bench/put.go @@ -112,7 +112,7 @@ func (u *Put) Start(ctx context.Context, wait chan struct{}) error { } else { op.OpType = http.MethodPost var verID string - verID, err = u.postPolicy(ctx, client, u.Bucket, obj) + verID, err = u.postPolicy(ctx, client.Client, u.Bucket, obj) if err == nil { res.Size = obj.Size res.VersionID = verID From dfb62fa9198d3c0aa0d33ba61ed1a2fc037df708 Mon Sep 17 00:00:00 2001 From: Manish Tiwary Date: Fri, 13 Feb 2026 03:54:03 +0000 Subject: [PATCH 3/6] Addressed review comments --- cli/client.go | 25 +++++++++++-------------- cli/client_transport.go | 16 ++++++++++++---- cli/flags.go | 2 +- pkg/bench/benchmark.go | 2 +- pkg/bench/multipart.go | 6 +++--- pkg/bench/multipart_put.go | 6 +++--- pkg/bench/ops.go | 15 --------------- pkg/bench/put.go | 2 +- 8 files changed, 32 insertions(+), 42 deletions(-) diff --git a/cli/client.go b/cli/client.go index 2860a01f..31fb3d79 100644 --- a/cli/client.go +++ b/cli/client.go @@ -52,7 +52,7 @@ const ( hostSelectTypeWeighed hostSelectType = "weighed" ) -func newClient(ctx *cli.Context) func() (cl *bench.Client, done func()) { +func newClient(ctx *cli.Context) func() (cl *minio.Client, done func()) { rawHost := ctx.String("host") hosts := parseHosts(rawHost, ctx.Bool("resolve-host")) @@ -63,7 +63,7 @@ func newClient(ctx *cli.Context) func() (cl *bench.Client, done func()) { cl, err := getClient(ctx, hosts[0], rawHost) fatalIf(probe.NewError(err), "Unable to create MinIO client") - return func() (*bench.Client, func()) { + return func() (*minio.Client, func()) { return cl, func() {} } } @@ -73,13 +73,13 @@ func newClient(ctx *cli.Context) func() (cl *bench.Client, done func()) { // Do round-robin. var current int var mu sync.Mutex - clients := make([]*bench.Client, len(hosts)) + clients := make([]*minio.Client, len(hosts)) for i := range hosts { cl, err := getClient(ctx, hosts[i], rawHost) fatalIf(probe.NewError(err), "Unable to create MinIO client") clients[i] = cl } - return func() (*bench.Client, func()) { + return func() (*minio.Client, func()) { mu.Lock() now := current % len(clients) current++ @@ -90,7 +90,7 @@ func newClient(ctx *cli.Context) func() (cl *bench.Client, done func()) { // Keep track of handed out clients. // Select random between the clients that have the fewest handed out. var mu sync.Mutex - clients := make([]*bench.Client, len(hosts)) + clients := make([]*minio.Client, len(hosts)) for i := range hosts { cl, err := getClient(ctx, hosts[i], rawHost) fatalIf(probe.NewError(err), "Unable to create MinIO client") @@ -125,7 +125,7 @@ func newClient(ctx *cli.Context) func() (cl *bench.Client, done func()) { } return earliestIdx } - return func() (*bench.Client, func()) { + return func() (*minio.Client, func()) { mu.Lock() idx := find() running[idx]++ @@ -166,7 +166,7 @@ func getClient( ctx *cli.Context, host string, originalHost string, -) (*bench.Client, error) { +) (*minio.Client, error) { u, _ := url.Parse(originalHost) if u == nil || u.Host == "" { scheme := "http" @@ -175,7 +175,7 @@ func getClient( } u, _ = url.Parse(fmt.Sprintf("%s://%s", scheme, originalHost)) } - domainName := u.Host + endpointHost := u.Host transport := clientTransport(ctx, host) var creds *credentials.Credentials localIP := clientListenIP @@ -206,7 +206,7 @@ func getClient( if ctx.Bool("tls") || ctx.Bool("ktls") { proto = "https" } - stsEndPoint := fmt.Sprintf("%s://%s", proto, domainName) + stsEndPoint := fmt.Sprintf("%s://%s", proto, endpointHost) creds, err = credentials.NewSTSWebIdentity(stsEndPoint, func() (*credentials.WebIdentityToken, error) { stsToken := ctx.String("sts-web-token") if stsTokenFile, hasFilePrefix := strings.CutPrefix(stsToken, "file:"); hasFilePrefix { @@ -231,7 +231,7 @@ func getClient( } else if ctx.String("lookup") == "path" { lookup = minio.BucketLookupPath } - cl, err := minio.New(domainName, &minio.Options{ + cl, err := minio.New(endpointHost, &minio.Options{ Creds: creds, Secure: ctx.Bool("tls") || ctx.Bool("ktls"), Region: ctx.String("region"), @@ -249,10 +249,7 @@ func getClient( cl.TraceOn(os.Stderr) } - return &bench.Client{ - Client: cl, - Host: u, - }, nil + return cl, nil } func clientTransport(ctx *cli.Context) http.RoundTripper { diff --git a/cli/client_transport.go b/cli/client_transport.go index 73b3638a..4da68bc3 100644 --- a/cli/client_transport.go +++ b/cli/client_transport.go @@ -69,14 +69,15 @@ func withDialTLSContext(dialer func(ctx context.Context, network, addr string) ( } } -func newClientTransport(ctx *cli.Context, targetIP string, options ...transportOption) http.RoundTripper { +func newClientTransport(ctx *cli.Context, endpoint string, options ...transportOption) http.RoundTripper { isTLS := ctx.Bool("tls") || ctx.Bool("ktls") tr := &http.Transport{ Proxy: http.ProxyFromEnvironment, DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { - // Extract the port from the original address (e.g., "s3.boston...:443") - _, port, err := net.SplitHostPort(addr) + // Extract the port from the original address + host, port, err := net.SplitHostPort(addr) if err != nil { + host = addr if isTLS { port = "443" } else { @@ -84,7 +85,14 @@ func newClientTransport(ctx *cli.Context, targetIP string, options ...transportO } } - dialAddr := net.JoinHostPort(targetIP, port) + dialAddr := addr + if endpoint != "" && endpoint != host { + targetHost, _, err := net.SplitHostPort(endpoint) + if err != nil { + targetHost = endpoint // It was just an IP/FQDN without a port + } + dialAddr = net.JoinHostPort(targetHost, port) + } return netDialer.DialContext(ctx, network, dialAddr) }, MaxIdleConnsPerHost: ctx.Int("concurrent"), diff --git a/cli/flags.go b/cli/flags.go index a06d0c89..58103ee1 100644 --- a/cli/flags.go +++ b/cli/flags.go @@ -218,7 +218,7 @@ var ioFlags = []cli.Flag{ }, cli.BoolFlag{ Name: "resolve-host", - Usage: "Resolve the host(s) ip(s) (including multiple A/AAAA records). This can break SSL certificates, use --insecure if so", + Usage: "Resolve the host(s) ip(s) (including multiple A/AAAA records)", Hidden: true, }, cli.IntFlag{ diff --git a/pkg/bench/benchmark.go b/pkg/bench/benchmark.go index 07bcb141..8aa29892 100644 --- a/pkg/bench/benchmark.go +++ b/pkg/bench/benchmark.go @@ -70,7 +70,7 @@ type Common struct { // Error should log an error similar to fmt.Print(data...) Error func(data ...any) - Client func() (cl *Client, done func()) + Client func() (cl *minio.Client, done func()) Collector Collector diff --git a/pkg/bench/multipart.go b/pkg/bench/multipart.go index 1ba16421..ee9de016 100644 --- a/pkg/bench/multipart.go +++ b/pkg/bench/multipart.go @@ -54,7 +54,7 @@ func (g *Multipart) InitOnce(ctx context.Context) error { g.UpdateStatus("Creating Object...") cl, done := g.Client() - c := minio.Core{Client: cl.Client} + c := minio.Core{Client: cl} defer done() uploadID, err := c.NewMultipartUpload(ctx, g.Bucket, g.ObjName, g.PutOpts) if err != nil { @@ -109,7 +109,7 @@ func (g *Multipart) Prepare(ctx context.Context) error { obj := src.Object() obj.Name = name client, cldone := g.Client() - core := minio.Core{Client: client.Client} + core := minio.Core{Client: client} op := Operation{ OpType: http.MethodPut, Thread: uint32(i), @@ -168,7 +168,7 @@ func (g *Multipart) Prepare(ctx context.Context) error { func (g *Multipart) AfterPrepare(ctx context.Context) error { cl, done := g.Client() - c := minio.Core{Client: cl.Client} + c := minio.Core{Client: cl} defer done() var parts []minio.CompletePart i := 1 diff --git a/pkg/bench/multipart_put.go b/pkg/bench/multipart_put.go index 148c51c7..89ac5e10 100644 --- a/pkg/bench/multipart_put.go +++ b/pkg/bench/multipart_put.go @@ -85,7 +85,7 @@ func (g *MultipartPut) createMultupartUpload(ctx context.Context, objectName str client, done := g.Client() defer done() - c := minio.Core{Client: client.Client} + c := minio.Core{Client: client} return c.NewMultipartUpload(nonTerm, g.Bucket, objectName, g.PutOpts) } @@ -125,7 +125,7 @@ func (g *MultipartPut) uploadParts(ctx context.Context, thread uint32, objectNam obj := g.Source().Object() client, done := g.Client() defer done() - core := minio.Core{Client: client.Client} + core := minio.Core{Client: client} op := Operation{ OpType: "PUTPART", Thread: thread*uint32(g.PartsConcurrency) + uint32(i), @@ -184,7 +184,7 @@ func (g *MultipartPut) completeMultipartUpload(ctx context.Context, objectName, nonTerm := context.Background() cl, done := g.Client() - c := minio.Core{Client: cl.Client} + c := minio.Core{Client: cl} defer done() _, err := c.CompleteMultipartUpload(nonTerm, g.Bucket, objectName, uploadID, parts, g.PutOpts) return err diff --git a/pkg/bench/ops.go b/pkg/bench/ops.go index 45e848a5..a6c1f691 100644 --- a/pkg/bench/ops.go +++ b/pkg/bench/ops.go @@ -23,7 +23,6 @@ import ( "fmt" "io" "math" - "net/url" "sort" "strconv" "strings" @@ -31,7 +30,6 @@ import ( "time" "github.com/dustin/go-humanize" - "github.com/minio/minio-go/v7" ) type Operations []Operation @@ -1232,16 +1230,3 @@ func StreamOperationsFromCSV(r io.Reader, analyzeOnly bool, offset, limit int, l } return nil } - -type Client struct { - *minio.Client - Host *url.URL -} - -// EndpointURL returns the endpoint URL. -func (c *Client) EndpointURL() *url.URL { - if c.Host != nil { - return c.Host - } - return c.Client.EndpointURL() -} diff --git a/pkg/bench/put.go b/pkg/bench/put.go index 50c0c3fb..7bdc1fc9 100644 --- a/pkg/bench/put.go +++ b/pkg/bench/put.go @@ -112,7 +112,7 @@ func (u *Put) Start(ctx context.Context, wait chan struct{}) error { } else { op.OpType = http.MethodPost var verID string - verID, err = u.postPolicy(ctx, client.Client, u.Bucket, obj) + verID, err = u.postPolicy(ctx, client, u.Bucket, obj) if err == nil { res.Size = obj.Size res.VersionID = verID From 226346eb5f31907c035c4ed6d8b9a4d9d02c80ad Mon Sep 17 00:00:00 2001 From: Manish Tiwary Date: Sun, 15 Feb 2026 23:56:23 +0000 Subject: [PATCH 4/6] addressing further review comments --- cli/client.go | 26 +++++++++++++++------ cli/client_ktls.go | 6 +---- cli/client_tls.go | 2 +- cli/client_transport.go | 52 ++++++++++++++++++++++------------------- cli/flags.go | 2 +- 5 files changed, 50 insertions(+), 38 deletions(-) diff --git a/cli/client.go b/cli/client.go index 31fb3d79..d905ceaa 100644 --- a/cli/client.go +++ b/cli/client.go @@ -60,7 +60,7 @@ func newClient(ctx *cli.Context) func() (cl *minio.Client, done func()) { case 0: fatalIf(probe.NewError(errors.New("no host defined")), "Unable to create MinIO client") case 1: - cl, err := getClient(ctx, hosts[0], rawHost) + cl, err := getClient(ctx, hosts[0]) fatalIf(probe.NewError(err), "Unable to create MinIO client") return func() (*minio.Client, func()) { @@ -75,7 +75,7 @@ func newClient(ctx *cli.Context) func() (cl *minio.Client, done func()) { var mu sync.Mutex clients := make([]*minio.Client, len(hosts)) for i := range hosts { - cl, err := getClient(ctx, hosts[i], rawHost) + cl, err := getClient(ctx, hosts[i]) fatalIf(probe.NewError(err), "Unable to create MinIO client") clients[i] = cl } @@ -92,7 +92,7 @@ func newClient(ctx *cli.Context) func() (cl *minio.Client, done func()) { var mu sync.Mutex clients := make([]*minio.Client, len(hosts)) for i := range hosts { - cl, err := getClient(ctx, hosts[i], rawHost) + cl, err := getClient(ctx, hosts[i]) fatalIf(probe.NewError(err), "Unable to create MinIO client") clients[i] = cl } @@ -165,18 +165,30 @@ func detectLocalIP(host string) string { func getClient( ctx *cli.Context, host string, - originalHost string, ) (*minio.Client, error) { - u, _ := url.Parse(originalHost) + // Initial parse + u, _ := url.Parse(host) + // Handle missing scheme (e.g., if host is "10.0.0.1:9000") if u == nil || u.Host == "" { scheme := "http" if ctx.Bool("tls") || ctx.Bool("ktls") { scheme = "https" } - u, _ = url.Parse(fmt.Sprintf("%s://%s", scheme, originalHost)) + // If 'host' was just "10.0.0.1:9000", url.Parse fails. We fix it here: + u, _ = url.Parse(fmt.Sprintf("%s://%s", scheme, host)) } + // Final validation + if u == nil || u.Host == "" { + return nil, fmt.Errorf("invalid host provided: %s", host) + } + // Set the logical endpoint (the name used for S3 signing) endpointHost := u.Host - transport := clientTransport(ctx, host) + // Determine if we are actually pinning to a specific IP/Host + pinTarget := "" + if host != endpointHost { + pinTarget = host + } + transport := clientTransport(ctx, pinTarget) var creds *credentials.Credentials localIP := clientListenIP if localIP == "" { diff --git a/cli/client_ktls.go b/cli/client_ktls.go index 0dcfc64f..53eb7073 100644 --- a/cli/client_ktls.go +++ b/cli/client_ktls.go @@ -21,7 +21,6 @@ import ( "context" "net" stdHttp "net/http" - "net/url" "os" "time" @@ -32,9 +31,6 @@ import ( func clientTransportKTLS(ctx *cli.Context, localIP string) stdHttp.RoundTripper { // Keep TLS config. - rawHost := ctx.String("host") - u, _ := url.Parse("https://" + rawHost) - sni := u.Hostname() tlsConfig := &tls.Config{ RootCAs: mustGetSystemCertPool(), // Can't use SSLv3 because of POODLE and BEAST @@ -42,7 +38,7 @@ func clientTransportKTLS(ctx *cli.Context, localIP string) stdHttp.RoundTripper // Can't use TLSv1.1 because of RC4 cipher usage MinVersion: tls.VersionTLS12, InsecureSkipVerify: ctx.Bool("insecure"), - ServerName: sni, + ServerName: sni, // Set only if pinning to an IP ClientSessionCache: tls.NewLRUClientSessionCache(1024), // up to 1024 nodes // Extra configs diff --git a/cli/client_tls.go b/cli/client_tls.go index e2471b42..45ce6136 100644 --- a/cli/client_tls.go +++ b/cli/client_tls.go @@ -19,8 +19,8 @@ package cli import ( "crypto/tls" + "net" "net/http" - "net/url" "os" "github.com/minio/cli" diff --git a/cli/client_transport.go b/cli/client_transport.go index 4da68bc3..f112fb10 100644 --- a/cli/client_transport.go +++ b/cli/client_transport.go @@ -72,29 +72,7 @@ func withDialTLSContext(dialer func(ctx context.Context, network, addr string) ( func newClientTransport(ctx *cli.Context, endpoint string, options ...transportOption) http.RoundTripper { isTLS := ctx.Bool("tls") || ctx.Bool("ktls") tr := &http.Transport{ - Proxy: http.ProxyFromEnvironment, - DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { - // Extract the port from the original address - host, port, err := net.SplitHostPort(addr) - if err != nil { - host = addr - if isTLS { - port = "443" - } else { - port = "80" - } - } - - dialAddr := addr - if endpoint != "" && endpoint != host { - targetHost, _, err := net.SplitHostPort(endpoint) - if err != nil { - targetHost = endpoint // It was just an IP/FQDN without a port - } - dialAddr = net.JoinHostPort(targetHost, port) - } - return netDialer.DialContext(ctx, network, dialAddr) - }, + Proxy: http.ProxyFromEnvironment, MaxIdleConnsPerHost: ctx.Int("concurrent"), WriteBufferSize: ctx.Int("sndbuf"), // Configure beyond 4KiB default buffer size. ReadBufferSize: ctx.Int("rcvbuf"), // Configure beyond 4KiB default buffer size. @@ -114,7 +92,33 @@ func newClientTransport(ctx *cli.Context, endpoint string, options ...transportO // See https://github.com/golang/go/issues/14275 ForceAttemptHTTP2: ctx.Bool("http2"), } - + // Only set DialContext manually when IP pinning is enabled (endpoint != "") + // This ensures proxies work out-of-the-box for standard runs. + if endpoint != "" { + tr.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + host = addr + port = "80" + if isTLS { + port = "443" + } + } + // If addr is actually the proxy (provided by ProxyFromEnvironment), + // host will NOT match our target. We should not pin the proxy's IP. + // We only pin if we aren't using a proxy for this specific request. + dialAddr := addr + targetHost, _, err := net.SplitHostPort(endpoint) + if err != nil { + targetHost = endpoint + } + // Only pin if the target host (IP/Domain) actually differs + if targetHost != host { + dialAddr = net.JoinHostPort(targetHost, port) + } + return netDialer.DialContext(ctx, network, dialAddr) + } + } for _, option := range options { option(tr) } diff --git a/cli/flags.go b/cli/flags.go index 58103ee1..2038bc1c 100644 --- a/cli/flags.go +++ b/cli/flags.go @@ -348,7 +348,7 @@ func getCommon(ctx *cli.Context, src func() generator.Source) bench.Common { rpsLimiter = rate.NewLimiter(rate.Limit(rpsLimit), 1) } // Parse hosts to get a target IP for the transport signature - hosts := parseHosts(ctx.String("host"), ctx.Bool("resolve-host")) + hosts := parseHosts(ctx.String("host"), false) if len(hosts) == 0 { fatalIf(probe.NewError(errors.New("no host defined")), "Unable to initialize transport") } From a1137f2e9a65e5c7219f3abd292e7314fed0b350 Mon Sep 17 00:00:00 2001 From: Manish Tiwary Date: Mon, 6 Apr 2026 16:35:59 +0000 Subject: [PATCH 5/6] cli: fix --resolve-host to preserve hostname for SNI and S3 signing --- cli/client.go | 132 ++++++++++++++++++++++++---------------- cli/client_default.go | 6 +- cli/client_ktls.go | 46 ++++++++++++-- cli/client_tls.go | 19 +++++- cli/client_transport.go | 62 ++++++++++--------- cli/flags.go | 8 +-- 6 files changed, 174 insertions(+), 99 deletions(-) diff --git a/cli/client.go b/cli/client.go index d905ceaa..142d0bf1 100644 --- a/cli/client.go +++ b/cli/client.go @@ -26,7 +26,6 @@ import ( "math/rand" "net" "net/http" - "net/url" "os" "strings" "sync" @@ -52,15 +51,21 @@ const ( hostSelectTypeWeighed hostSelectType = "weighed" ) +// hostPair holds a resolved host and the original hostname it was resolved from. +// originalHost is "" when --resolve-host is not used (no pinning needed). +type hostPair struct { + resolved string // IP:port to dial + originalHost string // original hostname (for S3 signing + SNI) +} + func newClient(ctx *cli.Context) func() (cl *minio.Client, done func()) { - rawHost := ctx.String("host") - hosts := parseHosts(rawHost, ctx.Bool("resolve-host")) + pairs := parseHostPairs(ctx.String("host"), ctx.Bool("resolve-host")) - switch len(hosts) { + switch len(pairs) { case 0: fatalIf(probe.NewError(errors.New("no host defined")), "Unable to create MinIO client") case 1: - cl, err := getClient(ctx, hosts[0]) + cl, err := getClient(ctx, pairs[0].resolved, pairs[0].originalHost) fatalIf(probe.NewError(err), "Unable to create MinIO client") return func() (*minio.Client, func()) { @@ -73,9 +78,9 @@ func newClient(ctx *cli.Context) func() (cl *minio.Client, done func()) { // Do round-robin. var current int var mu sync.Mutex - clients := make([]*minio.Client, len(hosts)) - for i := range hosts { - cl, err := getClient(ctx, hosts[i]) + clients := make([]*minio.Client, len(pairs)) + for i := range pairs { + cl, err := getClient(ctx, pairs[i].resolved, pairs[i].originalHost) fatalIf(probe.NewError(err), "Unable to create MinIO client") clients[i] = cl } @@ -90,20 +95,20 @@ func newClient(ctx *cli.Context) func() (cl *minio.Client, done func()) { // Keep track of handed out clients. // Select random between the clients that have the fewest handed out. var mu sync.Mutex - clients := make([]*minio.Client, len(hosts)) - for i := range hosts { - cl, err := getClient(ctx, hosts[i]) + clients := make([]*minio.Client, len(pairs)) + for i := range pairs { + cl, err := getClient(ctx, pairs[i].resolved, pairs[i].originalHost) fatalIf(probe.NewError(err), "Unable to create MinIO client") clients[i] = cl } - running := make([]int, len(hosts)) - lastFinished := make([]time.Time, len(hosts)) + running := make([]int, len(pairs)) + lastFinished := make([]time.Time, len(pairs)) { // Start with a random host now := time.Now() - off := rand.New(rand.NewSource(time.Now().UnixNano())).Intn(len(hosts)) + off := rand.New(rand.NewSource(time.Now().UnixNano())).Intn(len(pairs)) for i := range lastFinished { - lastFinished[i] = now.Add(time.Duration(i + off%len(hosts))) + lastFinished[i] = now.Add(time.Duration(i + off%len(pairs))) } } find := func() int { @@ -162,39 +167,19 @@ func detectLocalIP(host string) string { } // getClient creates a client with the specified host and the options set in the context. -func getClient( - ctx *cli.Context, - host string, -) (*minio.Client, error) { - // Initial parse - u, _ := url.Parse(host) - // Handle missing scheme (e.g., if host is "10.0.0.1:9000") - if u == nil || u.Host == "" { - scheme := "http" - if ctx.Bool("tls") || ctx.Bool("ktls") { - scheme = "https" - } - // If 'host' was just "10.0.0.1:9000", url.Parse fails. We fix it here: - u, _ = url.Parse(fmt.Sprintf("%s://%s", scheme, host)) - } - // Final validation - if u == nil || u.Host == "" { - return nil, fmt.Errorf("invalid host provided: %s", host) - } - // Set the logical endpoint (the name used for S3 signing) - endpointHost := u.Host - // Determine if we are actually pinning to a specific IP/Host - pinTarget := "" - if host != endpointHost { - pinTarget = host - } - transport := clientTransport(ctx, pinTarget) +// host is the resolved IP:port to dial; originalHost is the logical hostname for S3 signing +// and SNI (empty when --resolve-host is not used). +func getClient(ctx *cli.Context, host, originalHost string) (*minio.Client, error) { var creds *credentials.Credentials localIP := clientListenIP if localIP == "" { localIP = detectLocalIP(host) } - transport := clientTransportWithLocalIP(ctx, localIP) + endpoint := host + if originalHost != "" { + endpoint = originalHost + } + transport := clientTransportWithLocalIP(ctx, localIP, host, originalHost) switch strings.ToUpper(ctx.String("signature")) { case "S3V4": // if Signature version '4' use NewV4 directly. @@ -218,7 +203,7 @@ func getClient( if ctx.Bool("tls") || ctx.Bool("ktls") { proto = "https" } - stsEndPoint := fmt.Sprintf("%s://%s", proto, endpointHost) + stsEndPoint := fmt.Sprintf("%s://%s", proto, endpoint) creds, err = credentials.NewSTSWebIdentity(stsEndPoint, func() (*credentials.WebIdentityToken, error) { stsToken := ctx.String("sts-web-token") if stsTokenFile, hasFilePrefix := strings.CutPrefix(stsToken, "file:"); hasFilePrefix { @@ -243,7 +228,7 @@ func getClient( } else if ctx.String("lookup") == "path" { lookup = minio.BucketLookupPath } - cl, err := minio.New(endpointHost, &minio.Options{ + cl, err := minio.New(endpoint, &minio.Options{ Creds: creds, Secure: ctx.Bool("tls") || ctx.Bool("ktls"), Region: ctx.String("region"), @@ -265,19 +250,21 @@ func getClient( } func clientTransport(ctx *cli.Context) http.RoundTripper { - return clientTransportWithLocalIP(ctx, "") + return clientTransportWithLocalIP(ctx, "", "", "") } // clientTransportWithLocalIP creates a transport that binds outbound connections // to localIP (empty string means no binding, OS picks the source address). -func clientTransportWithLocalIP(ctx *cli.Context, localIP string) http.RoundTripper { +// When resolvedHost and originalHost are both non-empty, the transport also rewrites +// dial addresses from originalHost to resolvedHost and sets TLS SNI from originalHost. +func clientTransportWithLocalIP(ctx *cli.Context, localIP, resolvedHost, originalHost string) http.RoundTripper { switch { case ctx.Bool("ktls"): - return clientTransportKTLS(ctx, localIP) + return clientTransportKTLS(ctx, localIP, resolvedHost, originalHost) case ctx.Bool("tls"): - return clientTransportTLS(ctx, localIP) + return clientTransportTLS(ctx, localIP, resolvedHost, originalHost) default: - return clientTransportDefault(ctx, localIP) + return clientTransportDefault(ctx, localIP, resolvedHost) } } @@ -354,6 +341,39 @@ func parseHosts(h string, resolveDNS bool) []string { return resolved } +// parseHostPairs parses the host string into hostPair slices. When resolveDNS is true, +// each hostname is resolved to its IPs and each IP becomes a separate pair carrying the +// original hostname so that S3 signing and SNI remain correct. +func parseHostPairs(h string, resolveDNS bool) []hostPair { + raw := parseHosts(h, false) + if !resolveDNS { + pairs := make([]hostPair, len(raw)) + for i, r := range raw { + pairs[i] = hostPair{resolved: r} + } + return pairs + } + var pairs []hostPair + for _, hostport := range raw { + host, port, _ := net.SplitHostPort(hostport) + if host == "" { + host = hostport + } + ips, err := net.LookupIP(host) + if err != nil { + fatalIf(probe.NewError(err), "Could not get IPs for "+hostport) + } + for _, ip := range ips { + resolved := ip.String() + if port != "" { + resolved = ip.String() + ":" + port + } + pairs = append(pairs, hostPair{resolved: resolved, originalHost: hostport}) + } + } + return pairs +} + // mustGetSystemCertPool - return system CAs or empty pool in case of error (or windows) func mustGetSystemCertPool() *x509.CertPool { rootCAs, err := certs.GetRootCAs("") @@ -367,15 +387,19 @@ func mustGetSystemCertPool() *x509.CertPool { } func newAdminClient(ctx *cli.Context) *madmin.AdminClient { - hosts := parseHosts(ctx.String("host"), ctx.Bool("resolve-host")) - if len(hosts) == 0 { + pairs := parseHostPairs(ctx.String("host"), ctx.Bool("resolve-host")) + if len(pairs) == 0 { fatalIf(probe.NewError(errors.New("no host defined")), "Unable to create MinIO admin client") } - cl, err := madmin.NewWithOptions(hosts[0], &madmin.Options{ + endpoint := pairs[0].resolved + if pairs[0].originalHost != "" { + endpoint = pairs[0].originalHost + } + cl, err := madmin.NewWithOptions(endpoint, &madmin.Options{ Creds: credentials.NewStaticV4(ctx.String("access-key"), ctx.String("secret-key"), ""), Secure: ctx.Bool("tls") || ctx.Bool("ktls"), - Transport: clientTransport(ctx, hosts[0]), + Transport: clientTransportWithLocalIP(ctx, "", pairs[0].resolved, pairs[0].originalHost), }) fatalIf(probe.NewError(err), "Unable to create MinIO admin client") cl.SetAppInfo(appName, pkg.Version) diff --git a/cli/client_default.go b/cli/client_default.go index 2ff7b64f..90265a7c 100644 --- a/cli/client_default.go +++ b/cli/client_default.go @@ -23,6 +23,10 @@ import ( "github.com/minio/cli" ) -func clientTransportDefault(ctx *cli.Context, localIP string) http.RoundTripper { +func clientTransportDefault(ctx *cli.Context, localIP, resolvedHost string) http.RoundTripper { + dialer := makeDialer(localIP) + if resolvedHost != "" { + return newClientTransport(ctx, withResolveHost(resolvedHost, resolvedHost, dialer, false)) + } return newClientTransport(ctx, withLocalAddr(localIP)) } diff --git a/cli/client_ktls.go b/cli/client_ktls.go index 53eb7073..c964ce4b 100644 --- a/cli/client_ktls.go +++ b/cli/client_ktls.go @@ -29,7 +29,15 @@ import ( "gitlab.com/go-extension/tls" ) -func clientTransportKTLS(ctx *cli.Context, localIP string) stdHttp.RoundTripper { +func clientTransportKTLS(ctx *cli.Context, localIP, resolvedHost, originalHost string) stdHttp.RoundTripper { + var sni string + if originalHost != "" { + if h, _, err := net.SplitHostPort(originalHost); err == nil { + sni = h + } else { + sni = originalHost + } + } // Keep TLS config. tlsConfig := &tls.Config{ RootCAs: mustGetSystemCertPool(), @@ -38,7 +46,7 @@ func clientTransportKTLS(ctx *cli.Context, localIP string) stdHttp.RoundTripper // Can't use TLSv1.1 because of RC4 cipher usage MinVersion: tls.VersionTLS12, InsecureSkipVerify: ctx.Bool("insecure"), - ServerName: sni, // Set only if pinning to an IP + ServerName: sni, ClientSessionCache: tls.NewLRUClientSessionCache(1024), // up to 1024 nodes // Extra configs @@ -57,16 +65,42 @@ func clientTransportKTLS(ctx *cli.Context, localIP string) stdHttp.RoundTripper netD := makeDialer(localIP) + getDialAddr := func(addr string) string { + if originalHost == "" || resolvedHost == "" { + return addr + } + host, port, err := net.SplitHostPort(addr) + if err != nil { + host = addr + port = "443" + } + targetHost, _, err := net.SplitHostPort(resolvedHost) + if err != nil { + targetHost = resolvedHost + } + if host != targetHost { + return net.JoinHostPort(targetHost, port) + } + return addr + } + // If we don't enable http/2, then using a custom DialTLSConext is the best choice. // It can improve performance by not using a compatibility layer. if !ctx.Bool("http2") { - dialer := &tls.Dialer{NetDialer: netD, Config: tlsConfig} - return newClientTransport(ctx, withDialTLSContext(dialer.DialContext)) + tlsDialer := &tls.Dialer{NetDialer: netD, Config: tlsConfig} + h1Dialer := func(ctx context.Context, network, addr string) (net.Conn, error) { + dialAddr := getDialAddr(addr) + return tlsDialer.DialContext(ctx, network, dialAddr) + } + return newClientTransport(ctx, withDialTLSContext(h1Dialer)) } tr := &http.Transport{ - Proxy: http.ProxyFromEnvironment, - DialContext: netD.DialContext, + Proxy: http.ProxyFromEnvironment, + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + dialAddr := getDialAddr(addr) + return netD.DialContext(ctx, network, dialAddr) + }, MaxIdleConnsPerHost: ctx.Int("concurrent"), WriteBufferSize: ctx.Int("sndbuf"), // Configure beyond 4KiB default buffer size. ReadBufferSize: ctx.Int("rcvbuf"), // Configure beyond 4KiB default buffer size. diff --git a/cli/client_tls.go b/cli/client_tls.go index 45ce6136..cc6d56ea 100644 --- a/cli/client_tls.go +++ b/cli/client_tls.go @@ -26,7 +26,15 @@ import ( "github.com/minio/cli" ) -func clientTransportTLS(ctx *cli.Context, localIP string) http.RoundTripper { +func clientTransportTLS(ctx *cli.Context, localIP, resolvedHost, originalHost string) http.RoundTripper { + var sni string + if originalHost != "" { + if h, _, err := net.SplitHostPort(originalHost); err == nil { + sni = h + } else { + sni = originalHost + } + } // Keep TLS config. tlsConfig := &tls.Config{ RootCAs: mustGetSystemCertPool(), @@ -43,5 +51,12 @@ func clientTransportTLS(ctx *cli.Context, localIP string) http.RoundTripper { tlsConfig.KeyLogWriter = os.Stdout } - return newClientTransport(ctx, withTLSConfig(tlsConfig), withLocalAddr(localIP)) + dialer := makeDialer(localIP) + opts := []transportOption{withTLSConfig(tlsConfig)} + if originalHost != "" { + opts = append(opts, withResolveHost(resolvedHost, originalHost, dialer, true)) + } else { + opts = append(opts, withLocalAddr(localIP)) + } + return newClientTransport(ctx, opts...) } diff --git a/cli/client_transport.go b/cli/client_transport.go index f112fb10..156032da 100644 --- a/cli/client_transport.go +++ b/cli/client_transport.go @@ -69,10 +69,40 @@ func withDialTLSContext(dialer func(ctx context.Context, network, addr string) ( } } -func newClientTransport(ctx *cli.Context, endpoint string, options ...transportOption) http.RoundTripper { - isTLS := ctx.Bool("tls") || ctx.Bool("ktls") +// withResolveHost rewrites the dial address from the logical hostname to the +// resolved IP when --resolve-host is active. Only activates when originalHost != "". +// Proxy connections are not rewritten (if addr doesn't match our target, it's a proxy). +func withResolveHost(resolvedHost, originalHost string, dialer *net.Dialer, isTLS bool) transportOption { + return func(transport *http.Transport) { + if originalHost == "" || resolvedHost == "" { + return + } + targetHost, _, err := net.SplitHostPort(resolvedHost) + if err != nil { + targetHost = resolvedHost + } + transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + host = addr + port = "80" + if isTLS { + port = "443" + } + } + dialAddr := addr + if host != targetHost { + dialAddr = net.JoinHostPort(targetHost, port) + } + return dialer.DialContext(ctx, network, dialAddr) + } + } +} + +func newClientTransport(ctx *cli.Context, options ...transportOption) http.RoundTripper { tr := &http.Transport{ Proxy: http.ProxyFromEnvironment, + DialContext: netDialer.DialContext, MaxIdleConnsPerHost: ctx.Int("concurrent"), WriteBufferSize: ctx.Int("sndbuf"), // Configure beyond 4KiB default buffer size. ReadBufferSize: ctx.Int("rcvbuf"), // Configure beyond 4KiB default buffer size. @@ -92,33 +122,7 @@ func newClientTransport(ctx *cli.Context, endpoint string, options ...transportO // See https://github.com/golang/go/issues/14275 ForceAttemptHTTP2: ctx.Bool("http2"), } - // Only set DialContext manually when IP pinning is enabled (endpoint != "") - // This ensures proxies work out-of-the-box for standard runs. - if endpoint != "" { - tr.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { - host, port, err := net.SplitHostPort(addr) - if err != nil { - host = addr - port = "80" - if isTLS { - port = "443" - } - } - // If addr is actually the proxy (provided by ProxyFromEnvironment), - // host will NOT match our target. We should not pin the proxy's IP. - // We only pin if we aren't using a proxy for this specific request. - dialAddr := addr - targetHost, _, err := net.SplitHostPort(endpoint) - if err != nil { - targetHost = endpoint - } - // Only pin if the target host (IP/Domain) actually differs - if targetHost != host { - dialAddr = net.JoinHostPort(targetHost, port) - } - return netDialer.DialContext(ctx, network, dialAddr) - } - } + for _, option := range options { option(tr) } diff --git a/cli/flags.go b/cli/flags.go index 2038bc1c..412e1c29 100644 --- a/cli/flags.go +++ b/cli/flags.go @@ -18,7 +18,6 @@ package cli import ( - "errors" "fmt" "os" "strings" @@ -347,11 +346,6 @@ func getCommon(ctx *cli.Context, src func() generator.Source) bench.Common { // set burst to 1 as limiter will always be called to wait for 1 token rpsLimiter = rate.NewLimiter(rate.Limit(rpsLimit), 1) } - // Parse hosts to get a target IP for the transport signature - hosts := parseHosts(ctx.String("host"), false) - if len(hosts) == 0 { - fatalIf(probe.NewError(errors.New("no host defined")), "Unable to initialize transport") - } // Create put options now, so ensure that trailing headers are set. putOpts := putOpts(ctx) return bench.Common{ @@ -364,7 +358,7 @@ func getCommon(ctx *cli.Context, src func() generator.Source) bench.Common { DiscardOutput: noOps, ExtraOut: extra, RpsLimiter: rpsLimiter, - Transport: clientTransport(ctx, hosts[0]), + Transport: clientTransport(ctx), UpdateStatus: statusln, TotalClients: 1, // Default to 1 for single-client mode } From 49c0078188292dae44fd985f415ab680c73366a8 Mon Sep 17 00:00:00 2001 From: Manish Tiwary Date: Thu, 21 May 2026 18:26:27 -0500 Subject: [PATCH 6/6] cli: fix resolve-host proxy bypass, IPv6 formatting, and originalHost threading - Fix inverted proxy bypass condition in withResolveHost and getDialAddr: rewrite only when addr matches the original hostname, not when it doesn't match the resolved IP (which incorrectly rewrote proxy addrs) - Fix IPv6 address formatting in parseHostPairs: use net.JoinHostPort instead of string concatenation to correctly bracket IPv6 addresses - Thread originalHost through clientTransportDefault so withResolveHost receives the hostname instead of the resolved IP as its second arg - Extract sniFromHost() helper to deduplicate SNI extraction from client_tls.go and client_ktls.go - Add comment acknowledging DNS resolution is intentionally pinned at startup with no TTL-based refresh --- cli/client.go | 7 +++++-- cli/client_default.go | 4 ++-- cli/client_ktls.go | 16 +++++++--------- cli/client_tls.go | 10 +--------- cli/client_transport.go | 23 +++++++++++++++++++++-- 5 files changed, 36 insertions(+), 24 deletions(-) diff --git a/cli/client.go b/cli/client.go index 142d0bf1..1bb24390 100644 --- a/cli/client.go +++ b/cli/client.go @@ -264,7 +264,7 @@ func clientTransportWithLocalIP(ctx *cli.Context, localIP, resolvedHost, origina case ctx.Bool("tls"): return clientTransportTLS(ctx, localIP, resolvedHost, originalHost) default: - return clientTransportDefault(ctx, localIP, resolvedHost) + return clientTransportDefault(ctx, localIP, resolvedHost, originalHost) } } @@ -359,6 +359,8 @@ func parseHostPairs(h string, resolveDNS bool) []hostPair { if host == "" { host = hostport } + // IPs are resolved once at startup and pinned for the duration of the + // benchmark run. TTL-based refresh is intentionally not supported. ips, err := net.LookupIP(host) if err != nil { fatalIf(probe.NewError(err), "Could not get IPs for "+hostport) @@ -366,7 +368,8 @@ func parseHostPairs(h string, resolveDNS bool) []hostPair { for _, ip := range ips { resolved := ip.String() if port != "" { - resolved = ip.String() + ":" + port + // Use net.JoinHostPort so IPv6 addresses get correct bracket formatting. + resolved = net.JoinHostPort(ip.String(), port) } pairs = append(pairs, hostPair{resolved: resolved, originalHost: hostport}) } diff --git a/cli/client_default.go b/cli/client_default.go index 90265a7c..919cd76b 100644 --- a/cli/client_default.go +++ b/cli/client_default.go @@ -23,10 +23,10 @@ import ( "github.com/minio/cli" ) -func clientTransportDefault(ctx *cli.Context, localIP, resolvedHost string) http.RoundTripper { +func clientTransportDefault(ctx *cli.Context, localIP, resolvedHost, originalHost string) http.RoundTripper { dialer := makeDialer(localIP) if resolvedHost != "" { - return newClientTransport(ctx, withResolveHost(resolvedHost, resolvedHost, dialer, false)) + return newClientTransport(ctx, withResolveHost(resolvedHost, originalHost, dialer, false)) } return newClientTransport(ctx, withLocalAddr(localIP)) } diff --git a/cli/client_ktls.go b/cli/client_ktls.go index c964ce4b..563195ca 100644 --- a/cli/client_ktls.go +++ b/cli/client_ktls.go @@ -30,14 +30,7 @@ import ( ) func clientTransportKTLS(ctx *cli.Context, localIP, resolvedHost, originalHost string) stdHttp.RoundTripper { - var sni string - if originalHost != "" { - if h, _, err := net.SplitHostPort(originalHost); err == nil { - sni = h - } else { - sni = originalHost - } - } + sni := sniFromHost(originalHost) // Keep TLS config. tlsConfig := &tls.Config{ RootCAs: mustGetSystemCertPool(), @@ -65,6 +58,9 @@ func clientTransportKTLS(ctx *cli.Context, localIP, resolvedHost, originalHost s netD := makeDialer(localIP) + // origHostname is the bare hostname used to identify which dial addresses + // should be rewritten to the resolved IP. Proxy addresses won't match. + origHostname := sniFromHost(originalHost) getDialAddr := func(addr string) string { if originalHost == "" || resolvedHost == "" { return addr @@ -78,7 +74,9 @@ func clientTransportKTLS(ctx *cli.Context, localIP, resolvedHost, originalHost s if err != nil { targetHost = resolvedHost } - if host != targetHost { + // Only rewrite when the target is our original hostname. + // Proxy addresses won't match and are left untouched. + if host == origHostname { return net.JoinHostPort(targetHost, port) } return addr diff --git a/cli/client_tls.go b/cli/client_tls.go index cc6d56ea..ad2fc96c 100644 --- a/cli/client_tls.go +++ b/cli/client_tls.go @@ -19,7 +19,6 @@ package cli import ( "crypto/tls" - "net" "net/http" "os" @@ -27,14 +26,7 @@ import ( ) func clientTransportTLS(ctx *cli.Context, localIP, resolvedHost, originalHost string) http.RoundTripper { - var sni string - if originalHost != "" { - if h, _, err := net.SplitHostPort(originalHost); err == nil { - sni = h - } else { - sni = originalHost - } - } + sni := sniFromHost(originalHost) // Keep TLS config. tlsConfig := &tls.Config{ RootCAs: mustGetSystemCertPool(), diff --git a/cli/client_transport.go b/cli/client_transport.go index 156032da..7e0d7537 100644 --- a/cli/client_transport.go +++ b/cli/client_transport.go @@ -69,9 +69,23 @@ func withDialTLSContext(dialer func(ctx context.Context, network, addr string) ( } } +// sniFromHost extracts the bare hostname from a host:port string for use as +// TLS ServerName (SNI). Returns "" if originalHost is empty, which lets Go +// derive SNI automatically from the dial address. +func sniFromHost(originalHost string) string { + if originalHost == "" { + return "" + } + if h, _, err := net.SplitHostPort(originalHost); err == nil { + return h + } + return originalHost +} + // withResolveHost rewrites the dial address from the logical hostname to the // resolved IP when --resolve-host is active. Only activates when originalHost != "". -// Proxy connections are not rewritten (if addr doesn't match our target, it's a proxy). +// Proxy connections are not rewritten: only connections whose target matches the +// original hostname are rewritten; proxy addresses are left unchanged. func withResolveHost(resolvedHost, originalHost string, dialer *net.Dialer, isTLS bool) transportOption { return func(transport *http.Transport) { if originalHost == "" || resolvedHost == "" { @@ -81,6 +95,9 @@ func withResolveHost(resolvedHost, originalHost string, dialer *net.Dialer, isTL if err != nil { targetHost = resolvedHost } + // Extract the bare original hostname once, outside the closure, + // so we can correctly identify which connections to rewrite. + origHostname := sniFromHost(originalHost) transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { host, port, err := net.SplitHostPort(addr) if err != nil { @@ -91,7 +108,9 @@ func withResolveHost(resolvedHost, originalHost string, dialer *net.Dialer, isTL } } dialAddr := addr - if host != targetHost { + // Only rewrite when the target is our original hostname. + // Proxy addresses won't match and are left untouched. + if host == origHostname { dialAddr = net.JoinHostPort(targetHost, port) } return dialer.DialContext(ctx, network, dialAddr)