diff --git a/Gemfile b/Gemfile index a8369ba6..de957d12 100644 --- a/Gemfile +++ b/Gemfile @@ -31,6 +31,7 @@ group :test do gem 'rspec', '~> 3.7.0', require: nil gem 'codeclimate-test-reporter', '~> 0.6.0', require: nil gem 'async-rspec' + gem 'fakefs' end group :development do diff --git a/Gemfile.lock b/Gemfile.lock index 4b2d8951..20feb961 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -76,6 +76,7 @@ GEM docile (1.1.5) dotenv (2.8.1) dry-initializer (3.0.3) + fakefs (2.5.0) falcon (0.42.3) async async-container (~> 0.16.0) @@ -228,7 +229,7 @@ GEM power_assert thor (1.2.1) thread_safe (0.3.6) - tilt (2.1.0) + tilt (2.3.0) timecop (0.9.1) timers (4.3.5) tomlrb (2.0.3) @@ -262,6 +263,7 @@ DEPENDENCIES codeclimate-test-reporter (~> 0.6.0) daemons (= 1.2.4) dotenv (~> 2.8.1) + fakefs falcon (~> 0.35) gli (~> 2.16.1) hiredis-client diff --git a/docs/configuration.md b/docs/configuration.md index add36c04..d75e50ac 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -26,6 +26,48 @@ variables. - Applies to: listener, worker, cron. - Format: string. +### CONFIG_REDIS_USERNAME + +- Redis ACL user name +- Optional. Defaults to empty. +- Applies to: listener, worker, cron. +- Format: string. + +### CONFIG_REDIS_PASSWORD + +- Redis ACL password +- Optional. Defaults to empty. +- Applies to: listener, worker, cron. +- Format: string. + +### CONFIG_REDIS_SSL + +- Whether use SSL to connect to Redis +- Optional. Defaults to empty. +- Applies to: listener, worker, cron. +- Format: true or false. + +### CONFIG_REDIS_CA_FILE + +- Certification authority to validate Redis server TLS connections with +- Optional. Defaults to empty. +- Applies to: listener, worker, cron. +- Format: path to file as string. + +### CONFIG_REDIS_CERT + +- The path to the client SSL certificate +- Optional. Defaults to empty. +- Applies to: listener, worker, cron. +- Format: path to file as string. + +### CONFIG_REDIS_PRIVATE_KEY + +- The path to the client SSL private key +- Optional. Defaults to empty. +- Applies to: listener, worker, cron. +- Format: path to file as string. + ### CONFIG_REDIS_SENTINEL_HOSTS - URL of Redis sentinels. @@ -33,6 +75,20 @@ variables. - Applies to: listener, worker, cron. - Format: list of URLs separated by ",". +### CONFIG_REDIS_SENTINEL_USERNAME + +- Sentinels ACL user name +- Optional. Defaults to empty. +- Applies to: listener, worker, cron. +- Format: string. + +### CONFIG_REDIS_SENTINEL_PASSWORD + +- Sentinels ACL password +- Optional. Defaults to empty. +- Applies to: listener, worker, cron. +- Format: string. + ### CONFIG_REDIS_SENTINEL_ROLE - Asks the sentinel for the URL of the master or a slave. @@ -80,6 +136,48 @@ sentinels. - Applies to: listener, worker, cron. - Format: string. +### CONFIG_QUEUES_USERNAME + +- Redis ACL user name +- Optional. Defaults to empty. +- Applies to: listener, worker, cron. +- Format: string. + +### CONFIG_QUEUES_PASSWORD + +- Redis ACL password +- Optional. Defaults to empty. +- Applies to: listener, worker, cron. +- Format: string. + +### CONFIG_QUEUES_SSL + +- Whether use SSL to connect to Redis +- Optional. Defaults to empty. +- Applies to: listener, worker, cron. +- Format: true or false. + +### CONFIG_QUEUES_CA_FILE + +- Certification authority certificate Redis should trust to accept TLS connections +- Optional. Defaults to empty. +- Applies to: listener, worker, cron. +- Format: path to file as string. + +### CONFIG_QUEUES_CERT + +- User certificate to connect to Redis through TLS +- Optional. Defaults to empty. +- Applies to: listener, worker, cron. +- Format: path to file as string. + +### CONFIG_QUEUES_PRIVATE_KEY + +- User key to connect to Redis through TLS +- Optional. Defaults to empty. +- Applies to: listener, worker, cron. +- Format: path to file as string. + ### CONFIG_QUEUES_SENTINEL_HOSTS - URL of Redis sentinels. @@ -87,6 +185,20 @@ sentinels. - Applies to: listener, worker, cron. - Format: list of URLs separated by ",". +### CONFIG_QUEUES_SENTINEL_USERNAME + +- Sentinels ACL user name +- Optional. Defaults to empty. +- Applies to: listener, worker, cron. +- Format: string. + +### CONFIG_QUEUES_SENTINEL_PASSWORD + +- Sentinels ACL password +- Optional. Defaults to empty. +- Applies to: listener, worker, cron. +- Format: string. + ### CONFIG_QUEUES_SENTINEL_ROLE - Asks the sentinel for the URL of the master or a slave. diff --git a/lib/3scale/backend.rb b/lib/3scale/backend.rb index 4c707ecf..70b7a403 100644 --- a/lib/3scale/backend.rb +++ b/lib/3scale/backend.rb @@ -60,8 +60,8 @@ class << self def new_resque_redis QueueStorage.connection( environment, - configuration, - ) + configuration + ) end def set_resque_redis diff --git a/lib/3scale/backend/async_redis/client.rb b/lib/3scale/backend/async_redis/client.rb new file mode 100644 index 00000000..72ee74b7 --- /dev/null +++ b/lib/3scale/backend/async_redis/client.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +# Based on https://github.com/socketry/async-redis/blob/v0.8.1/examples/auth/wrapper.rb + +require 'async/redis/client' +require '3scale/backend/async_redis/endpoint_helpers' +require '3scale/backend/async_redis/sentinels_client_acl_tls' +require '3scale/backend/async_redis/protocol/extended_resp2' + +module ThreeScale + module Backend + module AsyncRedis + # Friendly client wrapper that supports SSL, AUTH and db SELECT + class Client + class << self + # @param opts [Hash] Redis connection options + # @return [Async::Redis::Client] + def call(opts) + uri = URI(opts[:url]) + + credentials = [ uri.user || opts[:username], uri.password || opts[:password]] + db = uri.path[1..-1].to_i if uri.path + + protocol = Protocol::ExtendedRESP2.new(db: db, credentials: credentials) + + if opts.key? :sentinels + SentinelsClientACLTLS.new(uri, protocol, opts) + else + host = uri.host || EndpointHelpers::DEFAULT_HOST + port = uri.port || EndpointHelpers::DEFAULT_PORT + endpoint = EndpointHelpers.prepare_endpoint(host, port, opts[:ssl], opts[:ssl_params]) + Async::Redis::Client.new(endpoint, protocol: protocol, limit: opts[:max_connections]) + end + end + alias :connect :call + end + end + end + end +end diff --git a/lib/3scale/backend/async_redis/endpoint_helpers.rb b/lib/3scale/backend/async_redis/endpoint_helpers.rb new file mode 100644 index 00000000..0edcd30b --- /dev/null +++ b/lib/3scale/backend/async_redis/endpoint_helpers.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'async/io' + +module ThreeScale + module Backend + module AsyncRedis + module EndpointHelpers + + DEFAULT_HOST = 'localhost'.freeze + DEFAULT_PORT = 6379 + + class << self + + # @param host [String] + # @param port [Integer] + # @param ssl_params [Hash] + # @return [Async::IO::Endpoint::Generic] + def prepare_endpoint(host, port, ssl = false, ssl_params = nil) + tcp_endpoint = Async::IO::Endpoint.tcp(host, port) + + if ssl + ssl_context = OpenSSL::SSL::SSLContext.new + ssl_context.set_params(format_ssl_params(ssl_params)) if ssl_params + return Async::IO::SSLEndpoint.new(tcp_endpoint, ssl_context: ssl_context) + end + + tcp_endpoint + end + + def format_ssl_params(ssl_params) + cert = ssl_params[:cert].to_s.strip + key = ssl_params[:key].to_s.strip + return ssl_params if cert.empty? && key.empty? + + updated_ssl_params = ssl_params.dup + updated_ssl_params[:cert] = OpenSSL::X509::Certificate.new(File.read(cert)) + updated_ssl_params[:key] = OpenSSL::PKey.read(File.read(key)) + + updated_ssl_params + end + end + end + end + end +end diff --git a/lib/3scale/backend/async_redis/protocol/extended_resp2.rb b/lib/3scale/backend/async_redis/protocol/extended_resp2.rb index 2e8a8031..631629db 100644 --- a/lib/3scale/backend/async_redis/protocol/extended_resp2.rb +++ b/lib/3scale/backend/async_redis/protocol/extended_resp2.rb @@ -8,14 +8,24 @@ module AsyncRedis module Protocol # Custom Redis Protocol supporting Redis logical DBs + # and ACL credentials class ExtendedRESP2 - def initialize(db: nil) + + attr_reader :credentials, :db + + def initialize(db: nil, credentials: []) @db = db + @credentials = credentials end def client(stream) client = Async::Redis::Protocol::RESP2.client(stream) + if @credentials.any? + client.write_request(["AUTH", *@credentials]) + client.read_response # Ignore response. + end + if @db client.write_request(["SELECT", @db]) client.read_response diff --git a/lib/3scale/backend/async_redis/sentinels_client_acl_tls.rb b/lib/3scale/backend/async_redis/sentinels_client_acl_tls.rb new file mode 100644 index 00000000..1cb88ea4 --- /dev/null +++ b/lib/3scale/backend/async_redis/sentinels_client_acl_tls.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'openssl' +require 'async/redis/sentinels' + +module ThreeScale + module Backend + module AsyncRedis + class SentinelsClientACLTLS < Async::Redis::SentinelsClient + def initialize(uri, protocol = Async::Redis::Protocol::RESP2, config, **options) + @master_name = uri.host + @sentinel_endpoints = config[:sentinels].map do |sentinel| + EndpointHelpers.prepare_endpoint(sentinel[:host], sentinel[:port], config[:ssl], config[:ssl_params]) + end + @role = config[:role] || :master + + @protocol = protocol + @config = config + @pool = connect(**options) + end + + private + + def resolve_master + @sentinel_endpoints.each do |sentinel_endpoint| + client = Async::Redis::Client.new(sentinel_endpoint, protocol: Protocol::ExtendedRESP2.new(credentials: @protocol.credentials)) + + begin + address = client.call('sentinel', 'get-master-addr-by-name', @master_name) + rescue Errno::ECONNREFUSED + next + end + + return EndpointHelpers.prepare_endpoint(address[0], address[1], @config[:ssl], @config[:ssl_params]) if address + end + + nil + end + + def resolve_slave + @sentinel_endpoints.each do |sentinel_endpoint| + client = Async::Redis::Client.new(sentinel_endpoint, protocol: Protocol::ExtendedRESP2.new(credentials: @protocol.credentials)) + + begin + reply = client.call('sentinel', 'slaves', @master_name) + rescue Errno::ECONNREFUSED + next + end + + slaves = available_slaves(reply) + next if slaves.empty? + + slave = select_slave(slaves) + return EndpointHelpers.prepare_endpoint(slave['ip'], slave['port'], @config[:ssl], @config[:ssl_params]) + end + + nil + end + end + end + end +end diff --git a/lib/3scale/backend/configuration.rb b/lib/3scale/backend/configuration.rb index 45cd6b67..3ef4ef39 100644 --- a/lib/3scale/backend/configuration.rb +++ b/lib/3scale/backend/configuration.rb @@ -39,11 +39,12 @@ def parse_int(value, default) config.workers_logger_formatter = :text # Add configuration sections - config.add_section(:queues, :master_name, :sentinels, :role, - :connect_timeout, :read_timeout, :write_timeout, :max_connections) - config.add_section(:redis, :url, :proxy, :sentinels, :role, - :connect_timeout, :read_timeout, :write_timeout, :max_connections, - :async) + config.add_section(:queues, :master_name, :username, :password, :ssl, :ssl_params, :sentinels, + :sentinel_username, :sentinel_password, :role, :connect_timeout, :read_timeout, :write_timeout, + :max_connections) + config.add_section(:redis, :url, :proxy, :username, :password, :ssl, :ssl_params, :sentinels, + :sentinel_username, :sentinel_password, :role, :connect_timeout, :read_timeout, :write_timeout, + :max_connections, :async) config.add_section(:hoptoad, :service, :api_key) config.add_section(:internal_api, :user, :password) config.add_section(:master, :metrics) diff --git a/lib/3scale/backend/storage_async/client.rb b/lib/3scale/backend/storage_async/client.rb index c989e72a..749af3bf 100644 --- a/lib/3scale/backend/storage_async/client.rb +++ b/lib/3scale/backend/storage_async/client.rb @@ -1,7 +1,7 @@ -require 'async/io' -require 'async/redis/client' -require 'async/redis/sentinels' -require '3scale/backend/async_redis/protocol/extended_resp2' +# frozen_string_literal: true + +require '3scale/backend/async_redis/endpoint_helpers' +require '3scale/backend/async_redis/client' module ThreeScale module Backend @@ -25,7 +25,7 @@ def instance(reset = false) @instance = new( Storage::Helpers.config_with( configuration.redis, - options: { default_url: "#{DEFAULT_HOST}:#{DEFAULT_PORT}" } + options: { default_url: "#{AsyncRedis::EndpointHelpers::DEFAULT_HOST}:#{AsyncRedis::EndpointHelpers::DEFAULT_PORT}" } ) ) else @@ -35,7 +35,7 @@ def instance(reset = false) end def initialize(opts) - @redis_async = initialize_client(opts) + @redis_async = AsyncRedis::Client.call(opts) end def call(*args) @@ -59,48 +59,6 @@ def pipelined(&block) def close @redis_async.close end - - private - - DEFAULT_HOST = 'localhost'.freeze - DEFAULT_PORT = 6379 - - def initialize_client(opts) - return init_host_client(opts) unless opts.key? :sentinels - - init_sentinels_client(opts) - end - - def init_host_client(opts) - endpoint = make_redis_endpoint(opts) - protocol = make_redis_protocol(opts) - Async::Redis::Client.new(endpoint, protocol: protocol, limit: opts[:max_connections]) - end - - def init_sentinels_client(opts) - uri = URI(opts[:url] || '') - name = uri.host - role = opts[:role] || :master - protocol = make_redis_protocol(opts) - - Async::Redis::SentinelsClient.new(name, opts[:sentinels], role, protocol) - end - - # RESP2 with support for logical DBs - def make_redis_protocol(opts) - uri = URI(opts[:url] || "") - db = uri.path[1..-1] - - ThreeScale::Backend::AsyncRedis::Protocol::ExtendedRESP2.new(db: db) - end - - def make_redis_endpoint(opts) - uri = URI(opts[:url] || "") - host = uri.host || DEFAULT_HOST - port = uri.port || DEFAULT_PORT - - Async::IO::Endpoint.tcp(host, port) - end end end end diff --git a/lib/3scale/backend/storage_helpers.rb b/lib/3scale/backend/storage_helpers.rb index 3a957338..4ca75c71 100644 --- a/lib/3scale/backend/storage_helpers.rb +++ b/lib/3scale/backend/storage_helpers.rb @@ -62,7 +62,8 @@ class << self # CONN_WHITELIST - Connection options that can be specified in config # Note: we don't expose reconnect_attempts until the bug above is fixed CONN_WHITELIST = [ - :connect_timeout, :read_timeout, :write_timeout, :max_connections + :connect_timeout, :read_timeout, :write_timeout, :max_connections, :username, :password, :sentinel_username, + :sentinel_password, :ssl, :ssl_params ].freeze private_constant :CONN_WHITELIST @@ -185,6 +186,7 @@ def ensure_url_param(options) def cfg_compact(options) empty = ->(_k,v) { v.to_s.strip.empty? } + options[:ssl_params]&.delete_if(&empty) options.delete_if(&empty) end @@ -278,7 +280,8 @@ def cfg_unix_path_handler(options) def cfg_defaults_handler(options, defaults) cfg_with_defaults = defaults.merge(ensure_url_param(options)) cfg_with_defaults = cfg_unix_path_handler(cfg_with_defaults) - cfg_with_defaults&.delete(:max_connections) unless options[:async] + cfg_with_defaults.delete(:max_connections) unless options[:async] + cfg_with_defaults[:ssl] ||= true if URI(options[:url].to_s).scheme == 'rediss' cfg_with_defaults end diff --git a/openshift/3scale_backend.conf b/openshift/3scale_backend.conf index 1e5de940..590b0e78 100644 --- a/openshift/3scale_backend.conf +++ b/openshift/3scale_backend.conf @@ -22,14 +22,34 @@ ThreeScale::Backend.configure do |config| config.internal_api.user = "#{ENV['CONFIG_INTERNAL_API_USER']}" config.internal_api.password = "#{ENV['CONFIG_INTERNAL_API_PASSWORD']}" config.queues.master_name = "#{ENV['CONFIG_QUEUES_MASTER_NAME']}" + config.queues.username = "#{ENV['CONFIG_QUEUES_USERNAME']}" + config.queues.password = "#{ENV['CONFIG_QUEUES_PASSWORD']}" + config.queues.ssl = parse_boolean_env('CONFIG_QUEUES_SSL') + config.queues.ssl_params = { + ca_file: "#{ENV['CONFIG_QUEUES_CA_FILE']}", + cert: "#{ENV['CONFIG_QUEUES_CERT']}", + key: "#{ENV['CONFIG_QUEUES_PRIVATE_KEY']}" + } config.queues.sentinels = "#{ENV['CONFIG_QUEUES_SENTINEL_HOSTS'] && !ENV['CONFIG_QUEUES_SENTINEL_HOSTS'].empty? ? ENV['CONFIG_QUEUES_SENTINEL_HOSTS'] : ENV['SENTINEL_HOSTS']}" + config.queues.sentinel_username = "#{ENV['CONFIG_QUEUES_SENTINEL_USERNAME']}" + config.queues.sentinel_password = "#{ENV['CONFIG_QUEUES_SENTINEL_PASSWORD']}" config.queues.role = "#{ENV['CONFIG_QUEUES_SENTINEL_ROLE']}".to_sym config.queues.connect_timeout = parse_int_env('CONFIG_QUEUES_CONNECT_TIMEOUT') config.queues.read_timeout = parse_int_env('CONFIG_QUEUES_READ_TIMEOUT') config.queues.write_timeout = parse_int_env('CONFIG_QUEUES_WRITE_TIMEOUT') config.queues.max_connections = parse_int_env('CONFIG_QUEUES_MAX_CONNS') config.redis.proxy = "#{ENV['CONFIG_REDIS_PROXY']}" + config.redis.username = "#{ENV['CONFIG_REDIS_USERNAME']}" + config.redis.password = "#{ENV['CONFIG_REDIS_PASSWORD']}" + config.redis.ssl = parse_boolean_env('CONFIG_REDIS_SSL') + config.redis.ssl_params = { + ca_file: "#{ENV['CONFIG_REDIS_CA_FILE']}", + cert: "#{ENV['CONFIG_REDIS_CERT']}", + key: "#{ENV['CONFIG_REDIS_PRIVATE_KEY']}" + } config.redis.sentinels = "#{ENV['CONFIG_REDIS_SENTINEL_HOSTS']}" + config.redis.sentinel_username = "#{ENV['CONFIG_REDIS_SENTINEL_USERNAME']}" + config.redis.sentinel_password = "#{ENV['CONFIG_REDIS_SENTINEL_PASSWORD']}" config.redis.role = "#{ENV['CONFIG_REDIS_SENTINEL_ROLE']}".to_sym config.redis.connect_timeout = parse_int_env('CONFIG_REDIS_CONNECT_TIMEOUT') config.redis.read_timeout = parse_int_env('CONFIG_REDIS_READ_TIMEOUT') diff --git a/spec/unit/queue_storage_spec.rb b/spec/unit/queue_storage_spec.rb index 7d04b412..579ec718 100644 --- a/spec/unit/queue_storage_spec.rb +++ b/spec/unit/queue_storage_spec.rb @@ -45,7 +45,7 @@ module Backend def is_sentinel?(connection) if ThreeScale::Backend.configuration.redis.async connector = connection.instance_variable_get(:@redis_async) - connector.instance_of?(Async::Redis::SentinelsClient) + connector.instance_of?(ThreeScale::Backend::AsyncRedis::SentinelsClientACLTLS) else connector = connection.instance_variable_get(:@client) .instance_variable_get(:@config) diff --git a/test/test_helpers/certificates.rb b/test/test_helpers/certificates.rb new file mode 100644 index 00000000..3f0ac6b5 --- /dev/null +++ b/test/test_helpers/certificates.rb @@ -0,0 +1,67 @@ +# Copyright © 2020 Nicky Peeters +# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +# documentation files (the “Software”), to deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all copies or substantial portions +# of the Software. +# THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO +# THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +require 'rubygems' +require 'openssl' + +module TestHelpers + module Certificates + def create_key(alg) + case alg + when :rsa + OpenSSL::PKey::RSA.new(2048) + when :dsa + OpenSSL::PKey::DSA.new(2048) + when :ec + OpenSSL::PKey::EC.generate("prime256v1") + end + end + + def create_cert(key = create_key(:rsa)) + public_key = get_public_key(key) + + subject = "/C=BE/O=Test/OU=Test/CN=Test" + + cert = OpenSSL::X509::Certificate.new + cert.subject = cert.issuer = OpenSSL::X509::Name.parse(subject) + cert.not_before = Time.now + cert.not_after = Time.now + 365 * 24 * 60 * 60 + cert.public_key = public_key + cert.serial = 0x0 + cert.version = 2 + + ef = OpenSSL::X509::ExtensionFactory.new + ef.subject_certificate = cert + ef.issuer_certificate = cert + cert.extensions = [ + ef.create_extension("basicConstraints","CA:TRUE", true), + ef.create_extension("subjectKeyIdentifier", "hash"), + # ef.create_extension("keyUsage", "cRLSign,keyCertSign", true), + ] + cert.add_extension ef.create_extension("authorityKeyIdentifier", + "keyid:always,issuer:always") + + cert.sign key, OpenSSL::Digest::SHA1.new + cert + end + + private + + def get_public_key(key) + return OpenSSL::PKey::EC.new key if key.is_a? OpenSSL::PKey::EC + + key.public_key + end + end +end diff --git a/test/unit/storage_async_test.rb b/test/unit/storage_async_test.rb index a043eab4..964361cd 100644 --- a/test/unit/storage_async_test.rb +++ b/test/unit/storage_async_test.rb @@ -1,7 +1,10 @@ +require 'fakefs/safe' require File.expand_path(File.dirname(__FILE__) + '/../test_helper') require '3scale/backend/storage_async' class StorageAsyncTest < Test::Unit::TestCase + include TestHelpers::Certificates + def test_basic_operations storage = StorageAsync::Client.instance(true) storage.del('foo') @@ -34,6 +37,11 @@ def test_redis_malformed_url end end + def test_redis_no_scheme + storage = StorageAsync::Client.send :new, url('backend-redis') + assert_client_config({ url: URI('redis://backend-redis:6379') }, storage) + end + def test_sentinels_connection_string config_obj = { url: 'redis://master-group-name', @@ -182,20 +190,98 @@ def test_sentinels_empty end end - def test_redis_no_scheme - storage = StorageAsync::Client.send :new, url('backend-redis') - assert_client_config({ url: URI('redis://backend-redis:6379') }, storage) + def test_tls_no_client_certificate + config_obj = { + url: 'rediss://localhost:46379/0', + ssl_params: { + ca_file: File.expand_path(File.join(__FILE__, '..', '..', '..', 'script', 'config', 'ca-root-cert.pem')) + } + } + storage = StorageAsync::Client.send :new, Storage::Helpers.config_with(config_obj) + assert_client_config(config_obj, storage) + end + + [:rsa, :dsa, :ec].each do |alg| + define_method "test_tls_client_cert_#{alg}" do + ca_file = create_cert + key = create_key alg + cert = create_cert key + FakeFS.with_fresh do + ca_file_path = File.expand_path(File.join(__FILE__, '..', '..', '..', 'script', 'config', 'ca-root-cert.pem')) + key_path = File.expand_path(File.join(__FILE__, '..', '..', '..', 'script', 'config', 'redis-dsa.pem')) + cert_path = File.expand_path(File.join(__FILE__, '..', '..', '..', 'script', 'config', 'redis-dsa.crt')) + FileUtils.mkdir_p(File.dirname(ca_file_path)) + File.write(ca_file_path, ca_file.to_pem) + File.write(key_path, key.to_pem) + File.write(cert_path, cert.to_pem) + config_obj = { + url: 'rediss://localhost:46379/0', + ssl_params: { + ca_file: ca_file_path, + cert: cert_path, + key: key_path + } + } + storage = StorageAsync::Client.send :new, Storage::Helpers.config_with(config_obj) + assert_client_config(config_obj, storage, alg) + end + end + end + + def test_acl + config_obj = { + url: 'redis://localhost:6379/0', + username: 'apisonator-test', + password: 'p4ssW0rd' + } + storage = StorageAsync::Client.send :new, Storage::Helpers.config_with(config_obj) + assert_client_config(config_obj, storage) + end + + def test_acl_tls + config_obj = { + url: 'rediss://localhost:46379/0', + ssl_params: { + ca_file: File.expand_path(File.join(__FILE__, '..', '..', '..', 'script', 'config', 'ca-root-cert.pem')) + }, + username: 'apisonator-test', + password: 'p4ssW0rd' + } + storage = StorageAsync::Client.send :new, Storage::Helpers.config_with(config_obj) + assert_client_config(config_obj, storage) end private - def assert_client_config(conf, conn) + def assert_client_config(conf, conn, test_cert_type = nil) client = conn.instance_variable_get(:@redis_async) url = URI(conf[:url]) host, port = client.endpoint.address assert_equal url.host, host assert_equal url.port, port + + unless conf[:username].to_s.strip.empty? && conf[:password].to_s.strip.empty? + assert_instance_of ThreeScale::Backend::AsyncRedis::Protocol::ExtendedRESP2, client.protocol + username, password = client.protocol.instance_variable_get(:@credentials) + assert_equal conf[:username], username + assert_equal conf[:password], password + end + + unless conf[:ssl_params].to_s.strip.empty? + assert_instance_of Async::IO::SSLEndpoint, client.endpoint + assert_equal conf[:ssl_params][:ca_file], client.endpoint.options[:ssl_context].send(:ca_file) + assert_instance_of(OpenSSL::X509::Certificate, client.endpoint.options[:ssl_context].cert) unless conf[:ssl_params][:cert].to_s.strip.empty? + + unless test_cert_type.to_s.strip.empty? + expected_classes = { + rsa: OpenSSL::PKey::RSA, + dsa: OpenSSL::PKey::DSA, + ec: OpenSSL::PKey::EC, + } + assert_instance_of(expected_classes[test_cert_type], client.endpoint.options[:ssl_context].key) unless conf[:ssl_params][:key].to_s.strip.empty? + end + end end def assert_sentinel_config(conf, conn) @@ -205,7 +291,7 @@ def assert_sentinel_config(conf, conn) role = conf[:role] || :master password = client.instance_variable_get(:@protocol).instance_variable_get(:@password) - assert_instance_of Async::Redis::SentinelsClient, client + assert_instance_of ThreeScale::Backend::AsyncRedis::SentinelsClientACLTLS, client assert_equal name, client.instance_variable_get(:@master_name) assert_equal role, client.instance_variable_get(:@role) diff --git a/test/unit/storage_sync_test.rb b/test/unit/storage_sync_test.rb index e2110e22..2cb6f6a0 100644 --- a/test/unit/storage_sync_test.rb +++ b/test/unit/storage_sync_test.rb @@ -244,6 +244,53 @@ def test_sentinels_empty end end + def test_tls_no_client_certificate + config_obj = { + url: 'rediss://localhost:46379/0', + ssl_params: { + ca_file: File.expand_path(File.join(__FILE__, '..', '..', '..', 'script', 'config', 'ca-root-cert.pem')) + } + } + storage = StorageSync.send :new, Storage::Helpers.config_with(config_obj) + assert_client_config(config_obj, storage) + end + + def test_tls_client_cert + config_obj = { + url: 'rediss://localhost:46379/0', + ssl_params: { + ca_file: File.expand_path(File.join(__FILE__, '..', '..', '..', 'script', 'config', 'ca-root-cert.pem')), + cert: File.expand_path(File.join(__FILE__, '..', '..', '..', 'script', 'config', 'redis-client.crt')), + key: File.expand_path(File.join(__FILE__, '..', '..', '..', 'script', 'config', 'redis-client.key')) + } + } + storage = StorageSync.send :new, Storage::Helpers.config_with(config_obj) + assert_client_config(config_obj, storage) + end + + def test_acl + config_obj = { + url: 'redis://localhost:6379/0', + username: 'apisonator-test', + password: 'p4ssW0rd' + } + storage = StorageSync.send :new, Storage::Helpers.config_with(config_obj) + assert_client_config(config_obj, storage) + end + + def test_acl_tls + config_obj = { + url: 'rediss://localhost:46379/0', + ssl_params: { + ca_file: File.expand_path(File.join(__FILE__, '..', '..', '..', 'script', 'config', 'ca-root-cert.pem')) + }, + username: 'apisonator-test', + password: 'p4ssW0rd' + } + storage = StorageSync.send :new, Storage::Helpers.config_with(config_obj) + assert_client_config(config_obj, storage) + end + private def assert_client_config(conf, conn) @@ -256,6 +303,15 @@ def assert_client_config(conf, conn) assert_equal url.host, config.host assert_equal url.port, config.port end + + assert_equal conf[:username] || 'default', config.username + assert_equal conf[:password], config.password + + unless conf[:ssl_params].to_s.strip.empty? + %i[ca_file cert key].each do |key| + assert_equal conf[:ssl_params][key], config.ssl_params[key] + end + end end def assert_sentinel_config(conf, client)