Skip to content
Open
105 changes: 79 additions & 26 deletions cli/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand All @@ -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
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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.
Expand All @@ -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 {
Expand All @@ -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"),
Expand All @@ -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)
}
}

Expand Down Expand Up @@ -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("")
Expand All @@ -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)
Expand Down
6 changes: 5 additions & 1 deletion cli/client_default.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
47 changes: 42 additions & 5 deletions cli/client_ktls.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
package cli

import (
"context"
"net"
stdHttp "net/http"
"os"
"time"
Expand All @@ -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(),
Expand All @@ -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
Expand All @@ -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.
Expand Down
21 changes: 19 additions & 2 deletions cli/client_tls.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -34,12 +43,20 @@ 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,
Comment thread
maniche1024 marked this conversation as resolved.
ClientSessionCache: tls.NewLRUClientSessionCache(1024), // up to 1024 nodes
}

if ctx.Bool("debug") {
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...)
}
30 changes: 30 additions & 0 deletions cli/client_transport.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion cli/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down