diff --git a/cli/client.go b/cli/client.go index fd65e9b1..142d0bf1 100644 --- a/cli/client.go +++ b/cli/client.go @@ -51,13 +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()) { - hosts := parseHosts(ctx.String("host"), ctx.Bool("resolve-host")) - switch len(hosts) { + pairs := parseHostPairs(ctx.String("host"), ctx.Bool("resolve-host")) + + 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()) { @@ -70,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 } @@ -87,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 { @@ -159,13 +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) { +// 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. @@ -189,7 +203,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, endpoint) 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 +228,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(endpoint, &minio.Options{ Creds: creds, Secure: ctx.Bool("tls") || ctx.Bool("ktls"), Region: ctx.String("region"), @@ -236,19 +250,21 @@ func getClient(ctx *cli.Context, host string) (*minio.Client, error) { } 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) } } @@ -325,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("") @@ -338,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), + 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 4778223e..c964ce4b 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" @@ -27,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(), @@ -36,6 +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, ClientSessionCache: tls.NewLRUClientSessionCache(1024), // up to 1024 nodes // Extra configs @@ -54,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 98a6f31b..cc6d56ea 100644 --- a/cli/client_tls.go +++ b/cli/client_tls.go @@ -19,13 +19,22 @@ package cli import ( "crypto/tls" + "net" "net/http" "os" "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(), @@ -34,6 +43,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 } @@ -41,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 f07b312f..156032da 100644 --- a/cli/client_transport.go +++ b/cli/client_transport.go @@ -69,6 +69,36 @@ func withDialTLSContext(dialer func(ctx context.Context, network, addr string) ( } } +// 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, diff --git a/cli/flags.go b/cli/flags.go index 6978ea3a..d9d032ab 100644 --- a/cli/flags.go +++ b/cli/flags.go @@ -217,7 +217,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{