Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

metricbeat/module/redis: Add TLS and username support for Redis module #35240

Merged
merged 15 commits into from
Jun 15, 2023
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
1 change: 1 addition & 0 deletions CHANGELOG-developer.next.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ The list below covers the major changes between 7.0.0-rc2 and main only.
- Add Okta API package for entity analytics. {pull}35478[35478]
- Add benchmarking to HTTPJSON input testing. {pull}35138[35138]
- Allow non-AWS endpoints for testing Filebeat awss3 input. {issue}35496[35496] {pull}35520[35520]
- Add AUTH (username) and SSL/TLS support for Redis module {pull}35240[35240]

==== Deprecated

Expand Down
17 changes: 16 additions & 1 deletion metricbeat/docs/modules/redis.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,23 @@ metricbeat.modules:
# - include_fields:
# fields: ["beat", "metricset", "redis.info.stats"]

# Redis AUTH username (Redis 6.0+). Empty by default.
#username: user

# Redis AUTH password. Empty by default.
#password: foobared
#password: pass

# Optional SSL/TLS (Redis 6.0+). By default is false.
#ssl.enabled: true

# List of root certificates for SSL/TLS server verification
#ssl.certificate_authorities: ["/etc/pki/root/ca.crt"]

# Certificate for SSL/TLS client authentication
#ssl.certificate: "/etc/pki/client/cert.crt"

# Client certificate key file
#ssl.key: "/etc/pki/client/cert.key"
----

[float]
Expand Down
17 changes: 16 additions & 1 deletion metricbeat/metricbeat.reference.yml
Original file line number Diff line number Diff line change
Expand Up @@ -930,8 +930,23 @@ metricbeat.modules:
# - include_fields:
# fields: ["beat", "metricset", "redis.info.stats"]

# Redis AUTH username (Redis 6.0+). Empty by default.
#username: user

# Redis AUTH password. Empty by default.
#password: foobared
#password: pass

# Optional SSL/TLS (Redis 6.0+). By default is false.
#ssl.enabled: true

# List of root certificates for SSL/TLS server verification
#ssl.certificate_authorities: ["/etc/pki/root/ca.crt"]

# Certificate for SSL/TLS client authentication
#ssl.certificate: "/etc/pki/client/cert.crt"

# Client certificate key file
#ssl.key: "/etc/pki/client/cert.key"

#------------------------------- Traefik Module -------------------------------
- module: traefik
Expand Down
17 changes: 16 additions & 1 deletion metricbeat/module/redis/_meta/config.reference.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,20 @@
# - include_fields:
# fields: ["beat", "metricset", "redis.info.stats"]

# Redis AUTH username (Redis 6.0+). Empty by default.
#username: user

# Redis AUTH password. Empty by default.
#password: foobared
#password: pass

# Optional SSL/TLS (Redis 6.0+). By default is false.
#ssl.enabled: true

# List of root certificates for SSL/TLS server verification
#ssl.certificate_authorities: ["/etc/pki/root/ca.crt"]

# Certificate for SSL/TLS client authentication
#ssl.certificate: "/etc/pki/client/cert.crt"

# Client certificate key file
#ssl.key: "/etc/pki/client/cert.key"
17 changes: 16 additions & 1 deletion metricbeat/module/redis/_meta/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,20 @@
# Max number of concurrent connections. Default: 10
#maxconn: 10

# Redis AUTH username (Redis 6.0+). Empty by default.
#username: user

# Redis AUTH password. Empty by default.
#password: foobared
#password: pass

# Optional SSL/TLS (Redis 6.0+). By default is false.
#ssl.enabled: true

# List of root certificates for SSL/TLS server verification
#ssl.certificate_authorities: ["/etc/pki/root/ca.crt"]

# Certificate for SSL/TLS client authentication
#ssl.certificate: "/etc/pki/client/cert.crt"

# Client certificate key file
#ssl.key: "/etc/pki/client/cert.key"
39 changes: 39 additions & 0 deletions metricbeat/module/redis/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Licensed to Elasticsearch B.V. under one or more contributor
// license agreements. See the NOTICE file distributed with
// this work for additional information regarding copyright
// ownership. Elasticsearch B.V. licenses this file to you under
// the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.

package redis

import (
"crypto/tls"
"time"

"github.com/elastic/elastic-agent-libs/transport/tlscommon"
)

type Config struct {
IdleTimeout time.Duration `config:"idle_timeout"`
Network string `config:"network"`
MaxConn int `config:"maxconn" validate:"min=1"`
TLS *tlscommon.Config `config:"ssl"`

UseTLSConfig *tls.Config
}

// DefaultConfig return default config for the redis module.
func DefaultConfig() Config {
return Config{Network: "tcp", MaxConn: 10}
}
78 changes: 54 additions & 24 deletions metricbeat/module/redis/metricset.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,15 @@
package redis

import (
"fmt"
"net/url"
"strconv"
"strings"
"time"

rd "github.com/gomodule/redigo/redis"
"github.com/pkg/errors"

"github.com/elastic/beats/v7/metricbeat/mb"
"github.com/elastic/elastic-agent-libs/transport/tlscommon"
)

// MetricSet for fetching Redis server information and statistics.
Expand All @@ -35,32 +35,39 @@ type MetricSet struct {
pool *Pool
}

// NewMetricSet creates the base for Redis metricsets
// NewMetricSet creates the base for Redis metricsets.
func NewMetricSet(base mb.BaseMetricSet) (*MetricSet, error) {
// Unpack additional configuration options.
config := struct {
IdleTimeout time.Duration `config:"idle_timeout"`
Network string `config:"network"`
MaxConn int `config:"maxconn" validate:"min=1"`
}{
Network: "tcp",
MaxConn: 10,
}
config := DefaultConfig()

err := base.Module().UnpackConfig(&config)
if err != nil {
return nil, errors.Wrap(err, "failed to read configuration")
return nil, fmt.Errorf("failed to read configuration: %w", err)
}

password, dbNumber, err := getPasswordDBNumber(base.HostData())
username, password, dbNumber, err := getUsernamePasswordDBNumber(base.HostData())
if err != nil {
return nil, errors.Wrap(err, "failed to getPasswordDBNumber from URI")
return nil, fmt.Errorf("failed to parse username, password and dbNumber from URI: %w", err)
}

if config.TLS.IsEnabled() {
tlsConfig, err := tlscommon.LoadTLSConfig(config.TLS)
if err != nil {
return nil, fmt.Errorf("could not load provided TLS configuration: %w", err)
}
config.UseTLSConfig = tlsConfig.ToConfig()
}

return &MetricSet{
BaseMetricSet: base,
pool: CreatePool(base.Host(), password, config.Network, dbNumber,
config.MaxConn, config.IdleTimeout, base.Module().Config().Timeout),
pool: CreatePool(
base.Host(),
username,
password,
dbNumber,
&config,
base.Module().Config().Timeout,
),
}, nil
}

Expand All @@ -80,11 +87,27 @@ func (m *MetricSet) OriginalDBNumber() uint {
return uint(m.pool.DBNumber())
}

func getPasswordDBNumber(hostData mb.HostData) (string, int, error) {
// If there are more than one place specified password/db-number, use password/db-number in query
// getUserPasswordDBNumber parses username, password and dbNumber from URI or else default
// is used (mentioned in config).
//
// As per security consideration RFC-2396: Uniform Resource Identifiers (URI): Generic Syntax
// https://www.rfc-editor.org/rfc/rfc2396.html#section-7
//
// """
// It is clearly unwise to use a URL that contains a password which is
// intended to be secret. In particular, the use of a password within
// the 'userinfo' component of a URL is strongly disrecommended except
// in those rare cases where the 'password' parameter is intended to be
// public.
// """
//
// In some environments, this is safe but not all. We shouldn't ideally take
// username and password from URI's userinfo or query parameters.
func getUsernamePasswordDBNumber(hostData mb.HostData) (string, string, int, error) {
// If there are more than one place specified username/password/db-number, use username/password/db-number in query
uriParsed, err := url.Parse(hostData.URI)
if err != nil {
return "", 0, errors.Wrapf(err, "failed to parse URL '%s'", hostData.URI)
return "", "", 0, fmt.Errorf("failed to parse URL '%s': %w", hostData.URI, err)
}

// get db-number from URI if it exists
Expand All @@ -94,17 +117,23 @@ func getPasswordDBNumber(hostData mb.HostData) (string, int, error) {
if db != "" {
database, err = strconv.Atoi(db)
if err != nil {
return "", 0, errors.Wrapf(err, "redis database in url should be an integer, found: %s", db)
return "", "", 0, fmt.Errorf("redis database in url should be an integer, found: %s: %w", db, err)
}
}
}

// get password from query and also check db-number
// get username and password from query and also check db-number
password := hostData.Password
username := hostData.User
if uriParsed.RawQuery != "" {
queryParsed, err := url.ParseQuery(uriParsed.RawQuery)
if err != nil {
return "", 0, errors.Wrapf(err, "failed to parse query string in '%s'", hostData.URI)
return "", "", 0, fmt.Errorf("failed to parse query string in '%s': %w", hostData.URI, err)
}

usr := queryParsed.Get("username")
if usr != "" {
username = usr
}

pw := queryParsed.Get("password")
Expand All @@ -116,9 +145,10 @@ func getPasswordDBNumber(hostData mb.HostData) (string, int, error) {
if db != "" {
database, err = strconv.Atoi(db)
if err != nil {
return "", 0, errors.Wrapf(err, "redis database in query should be an integer, found: %s", db)
return "", "", 0, fmt.Errorf("redis database in query should be an integer, found: %s: %w", db, err)
}
}
}
return password, database, nil

return username, password, database, nil
}
37 changes: 30 additions & 7 deletions metricbeat/module/redis/metricset_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,57 +31,80 @@ func TestGetPasswordDBNumber(t *testing.T) {
cases := []struct {
title string
hostData mb.HostData
expectedUser string
expectedPassword string
expectedDatabase int
}{
{
"test redis://127.0.0.1:6379 without password",
mb.HostData{URI: "redis://127.0.0.1:6379", Password: ""},
"",
"",
0,
},
{
"test redis uri with password in URI user info field",
"test redis URI with password in userinfo",
mb.HostData{URI: "redis://:password@127.0.0.2:6379", Password: "password"},
"",
"password",
0,
},
{
"test redis uri with password in query field",
"test redis URI with password in query parameter",
mb.HostData{URI: "redis://127.0.0.1:6379?password=test", Password: ""},
"",
"test",
0,
},
{
"test redis uri with password and db in query field",
"test redis URI with password and db in query parameter",
mb.HostData{URI: "redis://127.0.0.1:6379?password=test&db=1", Password: ""},
"",
"test",
1,
},
{
"test redis uri with password in URI user info field and query field",
"test redis URI with password in userinfo and URI's query parameter",
mb.HostData{URI: "redis://:password1@127.0.0.2:6379?password=password2", Password: "password1"},
"",
"password2",
0,
},
{
"test redis uri with db number in URI",
"test redis URI with db number in URI's query parameter and password in userinfo",
mb.HostData{URI: "redis://:password1@127.0.0.2:6379/1", Password: "password1"},
"",
"password1",
1,
},
{
"test redis uri with db number in URI and query field",
"test redis URI with db number and password in URI's query parameter and password in userinfo",
mb.HostData{URI: "redis://:password1@127.0.0.2:6379/1?password=password2&db=2", Password: "password1"},
"",
"password2",
2,
},
{
"test redis URI with db number, user and password in URI's query parameter and password in userinfo",
mb.HostData{URI: "redis://antirez:password1@127.0.0.2:6379/1?password=password2&db=2", User: "antirez", Password: "password1"},
"antirez",
"password2",
2,
},
{
"test redis URI with db number, user & password in URI's query parameter and user & password in userinfo",
mb.HostData{URI: "redis://antirez:password1@127.0.0.2:6379/1?username=salvatore&password=password2&db=2", User: "antirez", Password: "password1"},
"salvatore",
"password2",
2,
},
}

for _, c := range cases {
t.Run(c.title, func(t *testing.T) {
password, database, err := getPasswordDBNumber(c.hostData)
username, password, database, err := getUsernamePasswordDBNumber(c.hostData)
assert.NoError(t, err)
assert.Equal(t, c.expectedUser, username)
assert.Equal(t, c.expectedPassword, password)
assert.Equal(t, c.expectedDatabase, database)
})
Expand Down
Loading