From a489b205fc94e7a78b1b4b3f884aca4963580efd Mon Sep 17 00:00:00 2001 From: sssciel Date: Fri, 21 Aug 2026 10:55:00 +0300 Subject: [PATCH] cluster: force TLS for https:// endpoints tt cluster publish/show timed out when connecting to a Tarantool Config Storage or etcd endpoint over https:// with SSL enabled server-side but no client certificate required, since transport selection only looked at explicit ssl_* URI params, ignoring the scheme. UriOpts now carries Scheme, and ConnectTarantool/ConnectEtcd force a secure connection when it's "https". Closes TNTP-8695 --- CHANGELOG.md | 3 + cli/cmd/cluster.go | 16 ++++- lib/cluster/etcd.go | 22 ++++++- lib/cluster/etcd_internal_test.go | 99 +++++++++++++++++++++++++++++++ lib/cluster/tarantool.go | 5 ++ lib/connect/uri.go | 3 + lib/connect/uri_test.go | 23 +++++++ 7 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 lib/cluster/etcd_internal_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 136249c93..4376d2565 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Fixed - `status`: replication errors were ignored. +- `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 diff --git a/cli/cmd/cluster.go b/cli/cmd/cluster.go index 89e033656..db5d79d75 100644 --- a/cli/cmd/cluster.go +++ b/cli/cmd/cluster.go @@ -19,7 +19,17 @@ import ( "github.com/tarantool/tt/lib/integrity" ) -const addAction = true +const ( + addAction = true + + clusterHTTPSHelp = `HTTPS connection behavior: +* The https:// scheme enables SSL for both etcd and Tarantool Config Storage + (TCS), even when no ssl_* URL arguments are specified. +* If no CA certificate is specified, host verification is skipped for both + etcd and TCS. +* Do not mix http:// and https:// addresses in the same endpoint list. If any + endpoint uses https://, SSL is enabled for all endpoints.` +) var showCtx = clustercmd.ShowCtx{ Username: "", @@ -98,7 +108,9 @@ var ( "param_name": "a name of an instance in the cluster configuration", "env_TT_CLI_auth": "Tarantool", "env_TT_CLI_ETCD_auth": "Etcd", - "footer": `The priority of credentials: + "footer": clusterHTTPSHelp + ` + +The priority of credentials: environment variables < command flags < URL credentials.`, }) diff --git a/lib/cluster/etcd.go b/lib/cluster/etcd.go index 0b77faa53..4bea2f34e 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, @@ -73,7 +73,7 @@ func ConnectEtcd(opts EtcdOpts) (*clientv3.Client, error) { } } - if opts.SkipHostVerify { + if shouldSkipEtcdHostVerify(opts) { tlsConfig.InsecureSkipVerify = true } } @@ -89,6 +89,24 @@ 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 +} + +// shouldSkipEtcdHostVerify returns true when host verification is explicitly +// disabled or HTTPS is used without a custom CA. +func shouldSkipEtcdHostVerify(opts EtcdOpts) bool { + return opts.SkipHostVerify || + (hasSecureEndpoint(opts.Endpoints) && opts.CaFile == "" && opts.CaPath == "") +} + // 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..9473976f5 --- /dev/null +++ b/lib/cluster/etcd_internal_test.go @@ -0,0 +1,99 @@ +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) + } + }) + } +} + +func TestShouldSkipEtcdHostVerify(t *testing.T) { + cases := []struct { + name string + opts EtcdOpts + expected bool + }{ + { + name: "plain HTTP without CA", + opts: EtcdOpts{ + Endpoints: []string{"http://localhost:2379"}, + }, + expected: false, + }, + { + name: "explicit skip", + opts: EtcdOpts{ + Endpoints: []string{"http://localhost:2379"}, + SkipHostVerify: true, + }, + expected: true, + }, + { + name: "HTTPS without CA", + opts: EtcdOpts{ + Endpoints: []string{"https://localhost:2379"}, + }, + expected: true, + }, + { + name: "mixed endpoints without CA", + opts: EtcdOpts{ + Endpoints: []string{ + "http://localhost:2379", + "https://localhost:2380", + }, + }, + expected: true, + }, + { + name: "HTTPS with CA file", + opts: EtcdOpts{ + Endpoints: []string{"https://localhost:2379"}, + CaFile: "ca.pem", + }, + expected: false, + }, + { + name: "HTTPS with CA path", + opts: EtcdOpts{ + Endpoints: []string{"https://localhost:2379"}, + CaPath: "certs", + }, + expected: false, + }, + { + name: "HTTPS with CA and explicit skip", + opts: EtcdOpts{ + Endpoints: []string{"https://localhost:2379"}, + CaFile: "ca.pem", + SkipHostVerify: true, + }, + expected: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := shouldSkipEtcdHostVerify(tc.opts); got != tc.expected { + t.Errorf("shouldSkipEtcdHostVerify() = %v, want %v", 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",