diff --git a/CHANGELOG.md b/CHANGELOG.md index 46231ac35..44102f050 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Fixed +- `tt cluster publish` / `tt cluster show`: fix a connection timeout to an + `https://` Tarantool Config Storage or etcd endpoint with SSL enabled but no + client certificate configured. + ## [2.14.0] - 2026-08-06 This release introduces cluster backup and restore: `tt backup` plans a diff --git a/lib/cluster/etcd.go b/lib/cluster/etcd.go index 0b77faa53..12f4639d3 100644 --- a/lib/cluster/etcd.go +++ b/lib/cluster/etcd.go @@ -51,7 +51,7 @@ type EtcdOpts struct { func ConnectEtcd(opts EtcdOpts) (*clientv3.Client, error) { var tlsConfig *tls.Config = nil if opts.KeyFile != "" || opts.CertFile != "" || opts.CaFile != "" || - opts.CaPath != "" || opts.SkipHostVerify { + opts.CaPath != "" || opts.SkipHostVerify || hasSecureEndpoint(opts.Endpoints) { tlsInfo := transport.TLSInfo{ CertFile: opts.CertFile, @@ -89,6 +89,17 @@ func ConnectEtcd(opts EtcdOpts) (*clientv3.Client, error) { }) } +// hasSecureEndpoint returns true if any of the endpoints uses the "https" +// scheme. +func hasSecureEndpoint(endpoints []string) bool { + for _, endpoint := range endpoints { + if strings.HasPrefix(endpoint, "https://") { + return true + } + } + return false +} + // EtcdGetter is the interface that wraps get from etcd method. type EtcdGetter interface { // Get retrieves key-value pairs for a key. diff --git a/lib/cluster/etcd_internal_test.go b/lib/cluster/etcd_internal_test.go new file mode 100644 index 000000000..be4d05e6a --- /dev/null +++ b/lib/cluster/etcd_internal_test.go @@ -0,0 +1,25 @@ +package cluster + +import "testing" + +func TestHasSecureEndpoint(t *testing.T) { + cases := []struct { + name string + endpoints []string + expected bool + }{ + {"empty", nil, false}, + {"plain http", []string{"http://localhost:2379"}, false}, + {"plain no scheme", []string{"localhost:2379"}, false}, + {"single https", []string{"https://localhost:2379"}, true}, + {"mixed", []string{"http://localhost:2379", "https://localhost:2380"}, true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := hasSecureEndpoint(tc.endpoints); got != tc.expected { + t.Errorf("hasSecureEndpoint(%v) = %v, want %v", tc.endpoints, got, tc.expected) + } + }) + } +} diff --git a/lib/cluster/tarantool.go b/lib/cluster/tarantool.go index 68797b383..2fac00ff2 100644 --- a/lib/cluster/tarantool.go +++ b/lib/cluster/tarantool.go @@ -581,6 +581,11 @@ func ConnectTarantool(uriOpts libconnect.UriOpts, SslCaFile: uriOpts.CaFile, SslCiphers: uriOpts.Ciphers, } + if uriOpts.Scheme == "https" { + // The "https" scheme means the user explicitly requested a secure + // connection, even if no ssl_* URI parameters were passed. + dialOpts.Transport = "ssl" + } dialer, err := dial.New(dialOpts) if err != nil { diff --git a/lib/connect/uri.go b/lib/connect/uri.go index 3c9f351b1..eb125f031 100644 --- a/lib/connect/uri.go +++ b/lib/connect/uri.go @@ -68,6 +68,8 @@ const ( type UriOpts struct { // Endpoint is a an endpoint to connect: [scheme://]host[:port]. Endpoint string + // Scheme is a scheme part of the URI (e.g. "tcp", "https"). + Scheme string // Host is a an address to connect: host[:port]. Host string // Prefix is a configuration prefix. @@ -235,6 +237,7 @@ func parseUriOpts(uri *url.URL) (UriOpts, error) { opts := UriOpts{ Endpoint: endpoint.String(), Host: uri.Host, + Scheme: uri.Scheme, Prefix: uri.Path, Tag: uri.Fragment, Username: uri.User.Username(), diff --git a/lib/connect/uri_test.go b/lib/connect/uri_test.go index ae72d08ad..8eabb79cc 100644 --- a/lib/connect/uri_test.go +++ b/lib/connect/uri_test.go @@ -285,6 +285,7 @@ func TestParseUriOpts(t *testing.T) { Url: "scheme://localhost", Opts: connect.UriOpts{ Endpoint: "scheme://localhost", + Scheme: "scheme", Host: "localhost", Timeout: defaultTimeout, }, @@ -294,6 +295,7 @@ func TestParseUriOpts(t *testing.T) { Url: "scheme://localhost:3013", Opts: connect.UriOpts{ Endpoint: "scheme://localhost:3013", + Scheme: "scheme", Host: "localhost:3013", Timeout: defaultTimeout, }, @@ -303,6 +305,7 @@ func TestParseUriOpts(t *testing.T) { Url: "scheme://user@localhost", Opts: connect.UriOpts{ Endpoint: "scheme://localhost", + Scheme: "scheme", Host: "localhost", Username: "user", Timeout: defaultTimeout, @@ -313,6 +316,7 @@ func TestParseUriOpts(t *testing.T) { Url: "scheme://user:pass@localhost", Opts: connect.UriOpts{ Endpoint: "scheme://localhost", + Scheme: "scheme", Host: "localhost", Username: "user", Password: "pass", @@ -324,6 +328,7 @@ func TestParseUriOpts(t *testing.T) { Url: "scheme://localhost/", Opts: connect.UriOpts{ Endpoint: "scheme://localhost", + Scheme: "scheme", Host: "localhost", Prefix: "/", Timeout: defaultTimeout, @@ -334,6 +339,7 @@ func TestParseUriOpts(t *testing.T) { Url: "scheme://localhost/prefix", Opts: connect.UriOpts{ Endpoint: "scheme://localhost", + Scheme: "scheme", Host: "localhost", Prefix: "/prefix", Timeout: defaultTimeout, @@ -344,6 +350,7 @@ func TestParseUriOpts(t *testing.T) { Url: "scheme://localhost/prefix#Fragment", Opts: connect.UriOpts{ Endpoint: "scheme://localhost", + Scheme: "scheme", Host: "localhost", Prefix: "/prefix", Tag: "Fragment", @@ -355,6 +362,7 @@ func TestParseUriOpts(t *testing.T) { Url: "scheme://localhost#Fragment", Opts: connect.UriOpts{ Endpoint: "scheme://localhost", + Scheme: "scheme", Host: "localhost", Tag: "Fragment", Timeout: defaultTimeout, @@ -365,6 +373,7 @@ func TestParseUriOpts(t *testing.T) { Url: "scheme://localhost/prefix?key=anykey", Opts: connect.UriOpts{ Endpoint: "scheme://localhost", + Scheme: "scheme", Host: "localhost", Prefix: "/prefix", Timeout: defaultTimeout, @@ -376,6 +385,7 @@ func TestParseUriOpts(t *testing.T) { Url: "scheme://localhost/prefix?name=anyname", Opts: connect.UriOpts{ Endpoint: "scheme://localhost", + Scheme: "scheme", Host: "localhost", Prefix: "/prefix", Timeout: defaultTimeout, @@ -387,6 +397,7 @@ func TestParseUriOpts(t *testing.T) { Url: "scheme://localhost?name=anyname#Fragment", Opts: connect.UriOpts{ Endpoint: "scheme://localhost", + Scheme: "scheme", Host: "localhost", Tag: "Fragment", Timeout: defaultTimeout, @@ -398,6 +409,7 @@ func TestParseUriOpts(t *testing.T) { Url: "scheme://localhost/prefix?name=", Opts: connect.UriOpts{ Endpoint: "scheme://localhost", + Scheme: "scheme", Host: "localhost", Prefix: "/prefix", Timeout: defaultTimeout, @@ -409,6 +421,7 @@ func TestParseUriOpts(t *testing.T) { Url: "scheme://localhost?ssl_key_file=/any/kfile", Opts: connect.UriOpts{ Endpoint: "scheme://localhost", + Scheme: "scheme", Host: "localhost", KeyFile: "/any/kfile", Timeout: defaultTimeout, @@ -419,6 +432,7 @@ func TestParseUriOpts(t *testing.T) { Url: "scheme://localhost?ssl_cert_file=/any/certfile", Opts: connect.UriOpts{ Endpoint: "scheme://localhost", + Scheme: "scheme", Host: "localhost", CertFile: "/any/certfile", Timeout: defaultTimeout, @@ -429,6 +443,7 @@ func TestParseUriOpts(t *testing.T) { Url: "scheme://localhost?ssl_ca_path=/any/capath", Opts: connect.UriOpts{ Endpoint: "scheme://localhost", + Scheme: "scheme", Host: "localhost", CaPath: "/any/capath", Timeout: defaultTimeout, @@ -439,6 +454,7 @@ func TestParseUriOpts(t *testing.T) { Url: "scheme://localhost?ssl_ca_file=/any/cafile", Opts: connect.UriOpts{ Endpoint: "scheme://localhost", + Scheme: "scheme", Host: "localhost", CaFile: "/any/cafile", Timeout: defaultTimeout, @@ -449,6 +465,7 @@ func TestParseUriOpts(t *testing.T) { Url: "scheme://localhost?verify_peer=true&verify_host=true", Opts: connect.UriOpts{ Endpoint: "scheme://localhost", + Scheme: "scheme", Host: "localhost", Timeout: defaultTimeout, }, @@ -458,6 +475,7 @@ func TestParseUriOpts(t *testing.T) { Url: "scheme://localhost?verify_peer=&verify_host=", Opts: connect.UriOpts{ Endpoint: "scheme://localhost", + Scheme: "scheme", Host: "localhost", Timeout: defaultTimeout, }, @@ -467,6 +485,7 @@ func TestParseUriOpts(t *testing.T) { Url: "scheme://localhost?verify_peer=false", Opts: connect.UriOpts{ Endpoint: "scheme://localhost", + Scheme: "scheme", Host: "localhost", SkipPeerVerify: true, Timeout: defaultTimeout, @@ -482,6 +501,7 @@ func TestParseUriOpts(t *testing.T) { Url: "scheme://localhost?verify_host=false", Opts: connect.UriOpts{ Endpoint: "scheme://localhost", + Scheme: "scheme", Host: "localhost", SkipHostVerify: true, Timeout: defaultTimeout, @@ -497,6 +517,7 @@ func TestParseUriOpts(t *testing.T) { Url: "scheme://localhost?timeout=5.5", Opts: connect.UriOpts{ Endpoint: "scheme://localhost", + Scheme: "scheme", Host: "localhost", Timeout: time.Duration(float64(5.5) * float64(time.Second)), }, @@ -506,6 +527,7 @@ func TestParseUriOpts(t *testing.T) { Url: "scheme://localhost?timeout=", Opts: connect.UriOpts{ Endpoint: "scheme://localhost", + Scheme: "scheme", Host: "localhost", Timeout: defaultTimeout, }, @@ -526,6 +548,7 @@ func TestParseUriOpts(t *testing.T) { "#Fragment", Opts: connect.UriOpts{ Endpoint: "scheme://localhost:2012", + Scheme: "scheme", Host: "localhost:2012", Prefix: "/prefix", Tag: "Fragment",