Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 14 additions & 2 deletions cli/cmd/cluster.go
Original file line number Diff line number Diff line change
Expand Up @@ -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: "",
Expand Down Expand Up @@ -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.`,
})

Expand Down
22 changes: 20 additions & 2 deletions lib/cluster/etcd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -73,7 +73,7 @@ func ConnectEtcd(opts EtcdOpts) (*clientv3.Client, error) {
}
}

if opts.SkipHostVerify {
if shouldSkipEtcdHostVerify(opts) {
tlsConfig.InsecureSkipVerify = true
}
}
Expand All @@ -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.
Expand Down
99 changes: 99 additions & 0 deletions lib/cluster/etcd_internal_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
5 changes: 5 additions & 0 deletions lib/cluster/tarantool.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions lib/connect/uri.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(),
Expand Down
23 changes: 23 additions & 0 deletions lib/connect/uri_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ func TestParseUriOpts(t *testing.T) {
Url: "scheme://localhost",
Opts: connect.UriOpts{
Endpoint: "scheme://localhost",
Scheme: "scheme",
Host: "localhost",
Timeout: defaultTimeout,
},
Expand All @@ -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,
},
Expand All @@ -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,
Expand All @@ -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",
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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",
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
},
Expand All @@ -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,
},
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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)),
},
Expand All @@ -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,
},
Expand All @@ -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",
Expand Down
Loading