diff --git a/.gitignore b/.gitignore index 32efcd9..650debe 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,12 @@ pkg/* bin/* -bootstrap/terraform/.terraform -bootstrap/terraform/terraform.tfstate -bootstrap/terraform/terraform.tfstate.backup bootstrap/terraform/local_environment_setup.sh -scripts/tests/tls \ No newline at end of file +bootstrap/*/data +scripts/tests/tls +**/.terraform* +**/terraform.tfstate* +**/terraform.rfstate.backup +*~ +*# +.#* diff --git a/.go-version b/.go-version index 57807d6..013173a 100644 --- a/.go-version +++ b/.go-version @@ -1 +1 @@ -1.22.0 +1.22.6 diff --git a/CHANGELOG.md b/CHANGELOG.md index 85ea21c..98b59af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ ## Unreleased +## v0.4.0 +* Bump go version to 1.22.6 +* Updated dependencies: + * https://github.com/hashicorp/vault-plugin-database-redis/pull/72 + ## v0.3.0 IMPROVEMENTS: * Updated dependencies: diff --git a/Makefile b/Makefile index 78ff894..4b76ce7 100644 --- a/Makefile +++ b/Makefile @@ -46,6 +46,31 @@ setup-env: teardown-env: cd bootstrap/terraform && terraform init && terraform destroy -auto-approve +.PHONY: setup-primary-secondary +setup-primary-secondary: + cd bootstrap/primary-secondary && terraform init && terraform apply -auto-approve + +.PHONY: teardown-primary-secondary +teardown-primary-secondary: + cd bootstrap/primary-secondary && terraform init && terraform destroy -auto-approve + + +.PHONY: setup-cluster +setup-cluster: + cd bootstrap/cluster && terraform init && terraform apply -auto-approve + +.PHONY: teardown-cluster +teardown-cluster: + cd bootstrap/cluster && terraform init && terraform destroy -auto-approve + +.PHONY: setup-sentinel +setup-sentinel: + cd bootstrap/sentinel && terraform init && terraform apply -auto-approve + +.PHONY: teardown-sentinel +teardown-sentinel: + cd bootstrap/sentinel && terraform init && terraform destroy -auto-approve + .PHONY: configure configure: dev @./scripts/configure.sh \ diff --git a/README.md b/README.md index 8fa263b..e9e2374 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,23 @@ A [Vault](https://www.vaultproject.io) plugin for Redis This project uses the database plugin interface introduced in Vault version 0.7.1. -The plugin supports the generation of static and dynamic user roles and root credential rotation on a stand alone redis server. +The plugin supports the generation of static and dynamic user roles and root credential rotation on the following Redis installations... + +- Single primary server +- Primary server and 1 - N secondary (readonly) replica servers +- Redis Cluster +- Redis Sentinel + +The plugin can also be configured to persist the generated credentials using the `presistence_mode` parameter, either to the servers local ACL file, using the Redis `ACL SAVE` command or to the Redis configuration file with the Redis `CONFIG REWRITE` command. The Redis installation must have either the `aclsave` file configured or a writable config file for this to work. + +In addition the plugin has been upgraded to support X509 certificate authentication as by default, Redis uses mutual TLS and requires clients to authenticate with a valid certificate (authenticated against trusted root CAs). It is necessary to set the Redis setting `tls-auth-clients no` to disable client authentication. ## Build Use `make dev` to build a development version of this plugin. **Please note:** In case of the following errors, while creating Redis connection in Vault, please build this plugin with `CGO_ENABLED=0 go build -ldflags='-extldflags=-static' -o vault-plugin-database-redis ./cmd/vault-plugin-database-redis/` command. More details on this error can be found [here](https://github.com/hashicorp/vault-plugin-database-redis/issues/1#issuecomment-1078415041). -````bash +```bash Error writing data to database/config/my-redis: Error making API request. URL: PUT http://127.0.0.1:8200/v1/database/config/my-redis @@ -20,12 +29,17 @@ Code: 400. Errors: * error creating database object: invalid database version: 2 errors occurred: * fork/exec /config/plugin/vault-plugin-database-redis: no such file or directory * fork/exec /config/plugin/vault-plugin-database-redis: no such file or directory -```` +``` ## Testing -To run tests, `go test` will first set up the docker.io/redis:latest database image, then execute a set of basic tests against it. To test against different redis images, for example 5.0-buster, set the environment variable `REDIS_VERSION=5.0-buster`. If you want to run the tests against a local redis installation or an already running redis container, set the environment variable `TEST_REDIS_HOST` before executing. +To run tests, `go test` will first set up the docker.io/redis:latest database image, then execute a set of basic tests against it. To test against different redis images, for example 5.0-buster, set the environment variable `REDIS_VERSION=5.0-buster`. If you want to run the tests against a local redis installation or an already running redis containerized installation, set the appropriate environment variables before executing. -**Note:** The tests assume that the redis database instance has a default user with the following ACL settings `user default on >default-pa55w0rd ~* +@all`. If it doesn't, you will need to align the Administrator username and password with the pre-set values in the `redis_test.go` file. +- `TEST_REDIS_PRIMARY_HOST` and `TEST_REDIS_PRIMARY_PORT` for a standalone server. +- `TEST_REDIS_PRIMARY_HOST`, `TEST_REDIS_PRIMARY_PORT` and `TEST_REDIS_SECONDARIES` for a server with primary and N secondary's. +- `TEST_REDIS_CLUSTER` for a Redis cluster installation. **Note:** One server and port combination is enough for testing as the plugin will be able to fetch the clusters topography. +- `TEST_REDIS_SENTINELS` and `TEST_REDIS_SENTINEL_MASTER_NAME` for a sentinel installation. + +**Note:** The tests assume that the redis database installation has a default user with the following ACL settings `user default on >default-pa55w0rd ~* +@all`. If it doesn't, you will need to align the Administrator username and password with the pre-set values in the `redis_test.go` file. The cluster, sentinel and primary-secondary terraform created Redis test installations populate the Redis servers `aclfile /tmp/users.acl` with the default user `default` with the same `default-pa55w0rd`. Set `VAULT_ACC=1` to execute all of the tests including the acceptance tests, or run just a subset of tests by using a command like `go test -run TestDriver/Init` for example. @@ -59,29 +73,73 @@ $ vault write sys/plugins/catalog/database/vault-plugin-database-redis sha256=$S At this stage you are now ready to initialize the plugin to connect to the redis db using unencrypted or encrypted communications. -Prior to initializing the plugin, ensure that you have created an administration account. Vault will use the user specified here to create/update/revoke database credentials. That user must have the appropriate rule `+@admin` to perform actions upon other database users. +Prior to initializing the plugin, ensure that you have created an administration account. Vault will use the user specified here to create/update/revoke database credentials. That user must have the appropriate rule `+@admin` to perform actions upon other database users. If you are using a Redis cluster then the user must have these two additional rules `+readonly +cluster`. ### Plugin Initialization -#### Standalone REDIS Server. +#### Standalone Redis Server. ```bash $ vault write database/config/my-redis plugin_name="vault-plugin-database-redis" \ - host="localhost" port=6379 username="Administrator" password="password" \ + primary_host="localhost" primary_port=6379 username="Administrator" password="password" \ allowed_roles="my-redis-*-role" # You should consider rotating the admin password. Note that if you do, the new password will never be made available # through Vault, so you should create a vault-specific database admin user for this. $ vault write -force database/rotate-root/my-redis - ``` +#### Primary Redis Server and read only secondary replicas. + +```bash + +CACERT=$(cat $CA_CERT_FILE) +TLSCert=$(cat $TLS_CERT_FILE) +TLSKey=$(cat $TLS_KEY_FILE) + +vault write database/config/my-redis plugin_name="vault-plugin-database-redis" \ + primary_host="master-server" primary_port="6379" \ + secondaries="redis-secondary-0:6379,redis-secondary-1:6379," \ + username="default" password="default-pa55w0rd" \ + allowed_roles="*" persistence_mode="REWRITE" \ + tls=true ca_cert="$CACERT" tls_cert="$TLSCert" tls_key="$TLSKey" +#Success! Data written to: database/config/my-redis +``` + +#### Redis Cluster. + +```bash +vault write database/config/my-redis plugin_name="vault-plugin-database-redis" \ + cluster="node-0:6379,node-1:6379,node-3:6379,node-4:6379" \ + username=default password=default-pa55w0rd \ + allowed_roles="*" persistence_mode="REWRITE" +#Success! Data written to: database/config/my-redis +``` + +#### Redis Sentinel. + +```bash + +CACERT=$(cat $CA_CERT_FILE) +TLSCert=$(cat $TLS_CERT_FILE) +TLSKey=$(cat $TLS_KEY_FILE) + +vault write database/config/my-redis plugin_name="vault-plugin-database-redis" \ + sentinels="172.27.0.6:26379,172.27.0.7:26379,172.27.0.5:26379" sentinel_master_name=dear_racer \ + sentinel_username="default" sentinel_password="default-pa55w0rd" \ + username=default password=default-pa55w0rd 'allowed_roles=*' \ + persistence_mode=ACLFILE \ + tls=true ca_cert="$CACERT" tls_cert="$TLSCert" tls_key="$TLSKey" +#Success! Data written to: database/config/my-redis +``` + +**Note:** A sentinel installation requires credentials for the primary and secondaries as well as credentials for the sentinel servers. In this example they are the same but best practice would be to have a sentinel user with minimal control permissions. For example: `ACL SETUSER sentinel-user ON >somepassword allchannels +multi +slaveof +ping +exec +subscribe +config|rewrite +role +publish +info +client|setname +client|kill +script|kill`. Also **note:** the plugin provisions credentials to the Redis servers the sentinels manage at this time, not to the sentinels themselves. ### Dynamic Role Creation When you create roles, you need to provide a JSON string containing the Redis ACL rules which are documented [here](https://redis.io/commands/acl-cat) or in the output of the `ACL CAT` redis command. ```bash -# if a creation_statement is not provided the user account will default to a read only user, '["~*", "+@read"]' that can read any key. +# if a creation_statement is not provided the user account will default to a read only user, '["~*", "+@read"]' that can read any key. $ vault write database/roles/my-redis-admin-role db_name=my-redis \ default_ttl="5m" max_ttl="1h" creation_statements='["+@admin"]' @@ -90,6 +148,17 @@ $ vault write database/roles/my-redis-read-foo-role db_name=my-redis \ Success! Data written to: database/roles/my-redis-read-foo-role ``` +**Note:** Starting from Redis 7.0, ACL rules can also be grouped into multiple distinct sets of rules, called selectors. Selectors are added by wrapping the rules in parentheses and providing them just like any other rule. In order to execute a command, either the root permissions (rules defined outside of parenthesis) or any of the selectors (rules defined inside parenthesis) must match the given command. For example: + +`ACL SETUSER virginia on +GET allkeys (+SET ~app1*)` + +This sets a user with two sets of permissions, one defined on the user and one defined with a selector. The root user permissions only allow executing the get command, but can be executed on any keys. The selector then grants a secondary set of permissions: access to the SET command to be executed on any key that starts with app1. Using multiple selectors allows you to grant permissions that are different depending on what keys are being accessed. + +```bash +vault write database/roles/selector-role db_name=my-redis default_ttl="5m" max_ttl="1h" \ + creation_statements='["~foo*", "+get", "(~bar* +get +set)"]' +``` + To retrieve the credentials for the dynamic accounts ```bash @@ -112,6 +181,15 @@ lease_renewable true password ZN6gdTKszk7oc9Oztc-o username V_TOKEN_MY-REDIS-READ-FOO-ROLE_PUAINND1FC5XQGRC0HIF_1608481734 +$ vault read database/creds/selector-role +Key Value +--- ----- +lease_id database/creds/selector-role/7NltInpVSc7lPTtybJbkT0Dn +lease_duration 5m +lease_renewable true +password -65RFBsvOCkWCfBwFIMN +username V_TOKEN_SELECTOR-ROLE_W2ZYZDCXFKNWS7N43WMV_1717101832 + ``` ### Static Role Creation @@ -182,11 +260,13 @@ A set of make targets are provided for quick and easy iterations when developing server running locally and accessible via the `vault` CLI. See this [documentation](https://github.com/hashicorp/vault#developing-vault) on how to get started with Vault. -1. `make setup-env` will start a Redis docker container and initialize a test user with the username `us3rn4m3` and passwod `user-pa55w0rd` -2. `source ./bootstrap/terraform/local_environment_setup.sh` will export the necessary environment variables generated from the setup step +1. `make setup-(env|cluster|sentinel|primary-secondary)` will start a Redis docker installation and initialize a test user with the username `default` and password `default-pa55w0rd`. The cluster, sentinel and primary-secondary installations use docker bridge networking and will only work on Linux servers. They default to fully encrypted with mutual TLS enabled. To run then in plain text mode, pass the `-var=use-tls=false` flag on the `terraform apply` command line. +2. `source ./bootstrap/terraform/local_environment_setup.sh` will export the necessary environment variables generated from the setup step. For the cluster, sentinel and primary-secondary installations, source the export-(cluster|sentinel|primary-secondary)-vars.sh file 3. `make configure` will build the plugin, register it in your local Vault server and run sample commands to verify everything is working 4. `make testacc` will run the acceptance tests against the Redis container created during the environment setup -5. `make teardown-env` will stop the Redis docker container with any resources generated alongside it such as network configs +5. `make teardown-(env|cluster|sentinel|primary-secondry)` will stop the Redis docker installation with any resources generated alongside it such as network configs When iterating, you can reload any local code changes with `make configure` as many times as desired to test the latest -modifications via the Vault CLI or API. \ No newline at end of file +modifications via the Vault CLI or API. + +**Note:** The docker bridge networking works with a fixed network subnet address of `192.168.200.0/28`. This means that the terraform generated Redis server certificate can be generated with a list of actual IP addresses removing the need to test using the `insecure_tls=true` in the tests. diff --git a/bootstrap/cluster/export-cluster-vars.sh b/bootstrap/cluster/export-cluster-vars.sh new file mode 100755 index 0000000..615c07e --- /dev/null +++ b/bootstrap/cluster/export-cluster-vars.sh @@ -0,0 +1,18 @@ +#!/bin/bash +HERE="$(dirname ${BASH_SOURCE})" + +cd $HERE + +export TEST_REDIS_CLUSTER=$(terraform output -json cluster-nodes | gojq 'join(":6379,") + ":6379"' | tr -d \") +export TEST_REDIS_TLS=$(terraform output -raw use-tls) +if [ $TEST_REDIS_TLS == "false" ] +then + export TEST_REDIS_TLS="" +fi +export CA_CERT_FILE=$PWD/data/ca.crt +export TLS_CERT_FILE=$PWD/data/tls.crt +export TLS_KEY_FILE=$PWD/data/tls.key + +unset TEST_REDIS_PRIMARY_HOST TEST_REDIS_PRIMARY_PORT TEST_REDIS_SECONDARIES TEST_REDIS_SENTINELS TEST_REDIS_SENTINEL_MASTER_NAME + +cd - diff --git a/bootstrap/cluster/files.tf b/bootstrap/cluster/files.tf new file mode 100644 index 0000000..304e1c3 --- /dev/null +++ b/bootstrap/cluster/files.tf @@ -0,0 +1,44 @@ + +resource "local_file" "redis-sh" { + content = <<-EOT +ANNOUNCE_IP=$1 +ANNOUNCE_PORT=$(expr $2) +ANNOUNCE_BUS_PORT=$(expr $ANNOUNCE_PORT + 100) + +CONF_FILE="/tmp/redis.conf" +ACL_FILE="/tmp/users.acl" + +# generate redis.conf file +%{if var.use-tls == false} +echo "port 6379 +%{else} +echo "port 0 +tls-port 6379 +#tls-auth-clients no +tls-cluster yes +tls-cert-file /tmp/data/tls.crt +tls-key-file /tmp/data/tls.key +tls-ca-cert-file /tmp/data/ca.crt +%{endif} +cluster-enabled yes +cluster-config-file nodes.conf +cluster-node-timeout 5000 +appendonly yes +loglevel debug +requirepass default-pa55w0rd +masterauth default-pa55w0rd +protected-mode no +#cluster-announce-ip $ANNOUNCE_IP +#cluster-announce-port $ANNOUNCE_PORT +#cluster-announce-bus-port $ANNOUNCE_BUS_PORT +aclfile $ACL_FILE +" >> $CONF_FILE + +echo "user default on sanitize-payload #338b13e36315b0a2114e0ea1b2157327e8310edb5faacbb9120b1f643ba1130b ~* &* +@all" > $ACL_FILE + +# start server +redis-server $CONF_FILE +EOT + filename = "${path.module}/data/redis.sh" +} + diff --git a/bootstrap/cluster/main.tf b/bootstrap/cluster/main.tf new file mode 100644 index 0000000..357e848 --- /dev/null +++ b/bootstrap/cluster/main.tf @@ -0,0 +1,58 @@ +provider "docker" {} + +resource "docker_image" "redis" { + name = "redis:7.2.3" + keep_locally = true +} + +resource "docker_container" "redis-nodes" { + #attach = true + count = 6 + image = docker_image.redis.image_id + name = "redis-node-${count.index}" + hostname = "redis-node-${count.index}" + network_mode = "bridge" + command = ["/tmp/data/redis.sh"] + #logs = true + + volumes { + host_path = "${path.cwd}/data" + container_path = "/tmp/data" + } + networks_advanced { + name = docker_network.private_network.name + aliases = ["redis-node-${count.index}"] + } +} +resource "docker_container" "redis-cluster-creator" { + #attach = true + image = docker_image.redis.image_id + name = "redis-cluster-creator" + network_mode = "bridge" + command = var.use-tls == true ? var.redis-tls-cluster-command : var.redis-cluster-command + logs = true + networks_advanced { + name = docker_network.private_network.name + aliases = ["redis-cluster-creator"] + } + volumes { + host_path = "${path.cwd}/data" + container_path = "/tmp/data" + } + depends_on = [ + docker_container.redis-nodes + ] +} + +resource "docker_network" "private_network" { + name = "redis-cluster-network" + ipam_driver = "default" + ipam_options = {} + ipv6 = false + options = {} + + ipam_config { + aux_address = {} + subnet = "192.168.200.0/28" + } +} diff --git a/bootstrap/cluster/ouputs.tf b/bootstrap/cluster/ouputs.tf new file mode 100644 index 0000000..b725c3f --- /dev/null +++ b/bootstrap/cluster/ouputs.tf @@ -0,0 +1,6 @@ +output "cluster-nodes" { + value = flatten([for o in docker_container.redis-nodes : o.network_data[0].ip_address]) +} +output "use-tls" { + value = var.use-tls +} diff --git a/bootstrap/cluster/terraform.auto.tfvars b/bootstrap/cluster/terraform.auto.tfvars new file mode 100644 index 0000000..c2df00e --- /dev/null +++ b/bootstrap/cluster/terraform.auto.tfvars @@ -0,0 +1,2 @@ + +use-tls = true diff --git a/bootstrap/cluster/terraform.tf b/bootstrap/cluster/terraform.tf new file mode 100644 index 0000000..5b07a66 --- /dev/null +++ b/bootstrap/cluster/terraform.tf @@ -0,0 +1,8 @@ +terraform { + required_providers { + docker = { + source = "kreuzwerker/docker" + version = "~> 3.0.1" + } + } +} diff --git a/bootstrap/cluster/tls.tf b/bootstrap/cluster/tls.tf new file mode 100644 index 0000000..ee21894 --- /dev/null +++ b/bootstrap/cluster/tls.tf @@ -0,0 +1,86 @@ + +resource "tls_private_key" "ca_private_key" { + algorithm = "RSA" +} +# +resource "local_file" "ca_key" { + content = tls_private_key.ca_private_key.private_key_pem + filename = "${path.module}/data/private.key" +} + +resource "tls_self_signed_cert" "ca_cert" { + private_key_pem = tls_private_key.ca_private_key.private_key_pem + + is_ca_certificate = true + + subject { + country = "US" + common_name = "Root CA" + } + + validity_period_hours = 72 + + allowed_uses = [ + "digital_signature", + "cert_signing", + "crl_signing", + ] +} + +resource "local_file" "ca_cert" { + content = tls_self_signed_cert.ca_cert.cert_pem + filename = "${path.module}/data/ca.crt" +} + +# Create private key for server certificate +resource "tls_private_key" "internal" { + algorithm = "RSA" +} + +resource "local_file" "internal_key" { + content = tls_private_key.internal.private_key_pem + filename = "${path.module}/data/tls.key" +} + +# Create CSR for for server certificate +resource "tls_cert_request" "internal_csr" { + + private_key_pem = tls_private_key.internal.private_key_pem + + dns_names = ["*.*.*.*"] + ip_addresses = ["192.168.200.1", "192.168.200.2", "192.168.200.3", "192.168.200.4", "192.168.200.5", + "192.168.200.6", "192.168.200.7", "192.168.200.0"] + + subject { + country = "US" + organizational_unit = "Development" + } +} + +# Sign Seerver Certificate by Private CA +resource "tls_locally_signed_cert" "internal" { + // CSR by the development servers + cert_request_pem = tls_cert_request.internal_csr.cert_request_pem + // CA Private key + ca_private_key_pem = tls_private_key.ca_private_key.private_key_pem + // CA certificate + ca_cert_pem = tls_self_signed_cert.ca_cert.cert_pem + + validity_period_hours = 24 + + set_subject_key_id = true + + allowed_uses = [ + "digital_signature", + "key_encipherment", + "server_auth", + "client_auth", + ] +} + +resource "local_file" "internal_cert" { + content = tls_locally_signed_cert.internal.cert_pem + filename = "${path.module}/data/tls.crt" +} + + diff --git a/bootstrap/cluster/variables.tf b/bootstrap/cluster/variables.tf new file mode 100644 index 0000000..55a2363 --- /dev/null +++ b/bootstrap/cluster/variables.tf @@ -0,0 +1,15 @@ + +variable "use-tls" { + description = "Do you want TLS or not, true or false?" + type = bool +} + +variable "redis-tls-cluster-command" { + type = list(string) + default = ["redis-cli", "--tls", "--cert", "/tmp/data/tls.crt", "--key", "/tmp/data/tls.key", "--cacert", "/tmp/data/ca.crt", "-a", "default-pa55w0rd", "--cluster", "create", "redis-node-0:6379", "redis-node-1:6379", "redis-node-2:6379", "redis-node-3:6379", "redis-node-4:6379", "redis-node-5:6379", "--cluster-replicas", "1", "--cluster-yes"] +} + +variable "redis-cluster-command" { + type = list(string) + default = ["redis-cli", "-a", "default-pa55w0rd", "--cluster", "create", "redis-node-0:6379", "redis-node-1:6379", "redis-node-2:6379", "redis-node-3:6379", "redis-node-4:6379", "redis-node-5:6379", "--cluster-replicas", "1", "--cluster-yes"] +} diff --git a/bootstrap/primary-secondary/export-primary-seondary-vars.sh b/bootstrap/primary-secondary/export-primary-seondary-vars.sh new file mode 100755 index 0000000..a5aefa9 --- /dev/null +++ b/bootstrap/primary-secondary/export-primary-seondary-vars.sh @@ -0,0 +1,20 @@ +#!/bin/bash +HERE="$(dirname ${BASH_SOURCE})" + +cd $HERE + +export TEST_REDIS_SECONDARIES=$(terraform output -json secondaries | gojq 'join(":6379,") + ":6379"' | tr -d \") +export TEST_REDIS_PRIMARY_HOST=$(terraform output -raw primary_host) +export TEST_REDIS_PRIMARY_PORT=6379 +export TEST_REDIS_TLS=$(terraform output -raw use-tls) +if [ $TEST_REDIS_TLS == "false" ] +then + export TEST_REDIS_TLS="" +fi +export CA_CERT_FILE=$PWD/data/ca.crt +export TLS_CERT_FILE=$PWD/data/tls.crt +export TLS_KEY_FILE=$PWD/data/tls.key + +unset TEST_REDIS_CLUSTER TEST_REDIS_SENTINELS TEST_REDIS_SENTINEL_MASTER_NAME + +cd - diff --git a/bootstrap/primary-secondary/files.tf b/bootstrap/primary-secondary/files.tf new file mode 100644 index 0000000..828a388 --- /dev/null +++ b/bootstrap/primary-secondary/files.tf @@ -0,0 +1,38 @@ + +resource "local_file" "redis-sh" { + content = <<-EOT +FLAG=$1 +MASTER=$2 +MASTER_PORT=$3 + +CONF_FILE="/tmp/redis.conf" +ACL_FILE="/tmp/users.acl" + +# generate redis.conf file +%{if var.use-tls == false} +echo "port 6379 +%{else} +echo "port 0 +tls-port 6379 +tls-cert-file /tmp/data/tls.crt +tls-key-file /tmp/data/tls.key +tls-ca-cert-file /tmp/data/ca.crt +#tls-auth-clients no +tls-replication yes +%{endif} +appendonly yes +loglevel debug +requirepass default-pa55w0rd +masterauth default-pa55w0rd +protected-mode no +aclfile $ACL_FILE +" > $CONF_FILE + +echo "user default on sanitize-payload #338b13e36315b0a2114e0ea1b2157327e8310edb5faacbb9120b1f643ba1130b ~* &* +@all" > $ACL_FILE + +# start server +redis-server $CONF_FILE $FLAG $MASTER $MASTER_PORT +EOT + filename = "${path.module}/data/redis.sh" +} + diff --git a/bootstrap/primary-secondary/main.tf b/bootstrap/primary-secondary/main.tf new file mode 100644 index 0000000..558ec7a --- /dev/null +++ b/bootstrap/primary-secondary/main.tf @@ -0,0 +1,57 @@ +provider "docker" {} + +resource "docker_image" "redis" { + name = "redis:7.2.3" + keep_locally = true +} + +resource "docker_container" "redis-master" { + image = docker_image.redis.image_id + name = "redis-master" + hostname = "redis-master" + network_mode = "bridge" + command = ["/tmp/data/redis.sh"] + logs = true + + volumes { + host_path = "${path.cwd}/data" + container_path = "/tmp/data" + } + networks_advanced { + name = docker_network.private_network.name + aliases = ["redis-master"] + } +} + +resource "docker_container" "redis-replica" { + count = 2 + image = docker_image.redis.image_id + name = "redis-replica-${count.index}" + hostname = "redis-replica-${count.index}" + network_mode = "bridge" + command = ["/tmp/data/redis.sh", "--replicaof", "redis-master", "6379"] + #logs = true + + volumes { + host_path = "${path.cwd}/data" + container_path = "/tmp/data" + } + networks_advanced { + name = docker_network.private_network.name + aliases = ["redis-replica-${count.index}"] + } +} + +resource "docker_network" "private_network" { + name = "prim-sec-network" + ipam_driver = "default" + ipam_options = {} + ipv6 = false + options = {} + + ipam_config { + aux_address = {} + subnet = "192.168.200.0/28" + } +} + diff --git a/bootstrap/primary-secondary/ouputs.tf b/bootstrap/primary-secondary/ouputs.tf new file mode 100644 index 0000000..9ecb6ab --- /dev/null +++ b/bootstrap/primary-secondary/ouputs.tf @@ -0,0 +1,9 @@ +output "primary_host" { + value = docker_container.redis-master.network_data[0].ip_address +} +output "secondaries" { + value = flatten([for o in docker_container.redis-replica : o.network_data[0].ip_address]) +} +output "use-tls" { + value = var.use-tls +} diff --git a/bootstrap/primary-secondary/terraform.auto.tfvars b/bootstrap/primary-secondary/terraform.auto.tfvars new file mode 100644 index 0000000..c2df00e --- /dev/null +++ b/bootstrap/primary-secondary/terraform.auto.tfvars @@ -0,0 +1,2 @@ + +use-tls = true diff --git a/bootstrap/primary-secondary/terraform.tf b/bootstrap/primary-secondary/terraform.tf new file mode 100644 index 0000000..5b07a66 --- /dev/null +++ b/bootstrap/primary-secondary/terraform.tf @@ -0,0 +1,8 @@ +terraform { + required_providers { + docker = { + source = "kreuzwerker/docker" + version = "~> 3.0.1" + } + } +} diff --git a/bootstrap/primary-secondary/tls.tf b/bootstrap/primary-secondary/tls.tf new file mode 100644 index 0000000..ee21894 --- /dev/null +++ b/bootstrap/primary-secondary/tls.tf @@ -0,0 +1,86 @@ + +resource "tls_private_key" "ca_private_key" { + algorithm = "RSA" +} +# +resource "local_file" "ca_key" { + content = tls_private_key.ca_private_key.private_key_pem + filename = "${path.module}/data/private.key" +} + +resource "tls_self_signed_cert" "ca_cert" { + private_key_pem = tls_private_key.ca_private_key.private_key_pem + + is_ca_certificate = true + + subject { + country = "US" + common_name = "Root CA" + } + + validity_period_hours = 72 + + allowed_uses = [ + "digital_signature", + "cert_signing", + "crl_signing", + ] +} + +resource "local_file" "ca_cert" { + content = tls_self_signed_cert.ca_cert.cert_pem + filename = "${path.module}/data/ca.crt" +} + +# Create private key for server certificate +resource "tls_private_key" "internal" { + algorithm = "RSA" +} + +resource "local_file" "internal_key" { + content = tls_private_key.internal.private_key_pem + filename = "${path.module}/data/tls.key" +} + +# Create CSR for for server certificate +resource "tls_cert_request" "internal_csr" { + + private_key_pem = tls_private_key.internal.private_key_pem + + dns_names = ["*.*.*.*"] + ip_addresses = ["192.168.200.1", "192.168.200.2", "192.168.200.3", "192.168.200.4", "192.168.200.5", + "192.168.200.6", "192.168.200.7", "192.168.200.0"] + + subject { + country = "US" + organizational_unit = "Development" + } +} + +# Sign Seerver Certificate by Private CA +resource "tls_locally_signed_cert" "internal" { + // CSR by the development servers + cert_request_pem = tls_cert_request.internal_csr.cert_request_pem + // CA Private key + ca_private_key_pem = tls_private_key.ca_private_key.private_key_pem + // CA certificate + ca_cert_pem = tls_self_signed_cert.ca_cert.cert_pem + + validity_period_hours = 24 + + set_subject_key_id = true + + allowed_uses = [ + "digital_signature", + "key_encipherment", + "server_auth", + "client_auth", + ] +} + +resource "local_file" "internal_cert" { + content = tls_locally_signed_cert.internal.cert_pem + filename = "${path.module}/data/tls.crt" +} + + diff --git a/bootstrap/primary-secondary/variables.tf b/bootstrap/primary-secondary/variables.tf new file mode 100644 index 0000000..1ccb52c --- /dev/null +++ b/bootstrap/primary-secondary/variables.tf @@ -0,0 +1,6 @@ + +variable "use-tls" { + description = "Do you want TLS or not, true or false?" + type = bool +} + diff --git a/bootstrap/sentinel/export-sentinel-vars.sh b/bootstrap/sentinel/export-sentinel-vars.sh new file mode 100755 index 0000000..25c2847 --- /dev/null +++ b/bootstrap/sentinel/export-sentinel-vars.sh @@ -0,0 +1,19 @@ +#!/bin/bash +HERE="$(dirname ${BASH_SOURCE})" + +cd $HERE + +export TEST_REDIS_SENTINELS=$(terraform output -json env_var | gojq 'join(":26379,") + ":26379"' | tr -d \") +export TEST_REDIS_SENTINEL_MASTER_NAME=$(terraform output -raw env_master) +export TEST_REDIS_TLS=$(terraform output -raw use-tls) +if [ $TEST_REDIS_TLS == "false" ] +then + export TEST_REDIS_TLS="" +fi +export CA_CERT_FILE=$PWD/data/ca.crt +export TLS_CERT_FILE=$PWD/data/tls.crt +export TLS_KEY_FILE=$PWD/data/tls.key + +unset TEST_REDIS_PRIMARY_HOST TEST_REDIS_PRIMARY_PORT TEST_REDIS_SECONDARIES TEST_REDIS_CLUSTER + +cd - diff --git a/bootstrap/sentinel/files.tf b/bootstrap/sentinel/files.tf new file mode 100644 index 0000000..b2d96ca --- /dev/null +++ b/bootstrap/sentinel/files.tf @@ -0,0 +1,86 @@ +resource "local_file" "sentinel-sh" { + content = <<-EOT +MASTER_IP=$(getent hosts $1) +SENTINEL_PORT=$(expr $2) +MASTER_NAME=$3 +#ANNOUNCE_IP=$(getent hosts $1) +#ANNOUNCE_PORT=$(expr $2) + +CONF_FILE="/tmp/sentinel.conf" +ACL_FILE="/tmp/users.acl" + +# generate sentinel.conf 7.x version +%{if var.use-tls == false} +echo "port $SENTINEL_PORT +%{else} +echo "port 0 +tls-port $SENTINEL_PORT +tls-cert-file /tmp/data/tls.crt +tls-key-file /tmp/data/tls.key +tls-ca-cert-file /tmp/data/ca.crt +#tls-auth-clients no +tls-replication yes +%{endif} +sentinel monitor $MASTER_NAME $${MASTER_IP%% *} 6379 2 +sentinel down-after-milliseconds $MASTER_NAME 50000 +sentinel failover-timeout $MASTER_NAME 60000 +sentinel parallel-syncs $MASTER_NAME 1 +sentinel auth-pass $MASTER_NAME default-pa55w0rd +sentinel auth-user $MASTER_NAME default +sentinel sentinel-user default +sentinel sentinel-pass default-pa55w0rd +#requirepass default-pa55w0rd +#sentinel announce-ip $${ANNOUNCE_IP%% *} +#sentinel announce-port $ANNOUNCE_PORT +aclfile $ACL_FILE +" > $CONF_FILE + +echo "user default on sanitize-payload #338b13e36315b0a2114e0ea1b2157327e8310edb5faacbb9120b1f643ba1130b ~* &* +@all +" > $ACL_FILE + +# start server +redis-server $CONF_FILE --sentinel +EOT + filename = "${path.module}/data/sentinel.sh" +} + +resource "local_file" "redis-sh" { + content = <<-EOT +#ANNOUNCE_IP=$1 +#ANNOUNCE_PORT=$(expr $2) +#ANNOUNCE_BUS_PORT=$(expr $ANNOUNCE_PORT + 100) +FLAG=$1 +MASTER=$2 +MASTER_PORT=$3 + +CONF_FILE="/tmp/redis.conf" +ACL_FILE="/tmp/users.acl" + +# generate redis.conf file +%{if var.use-tls == false} +echo "port 6379 +%{else} +echo "port 0 +tls-port 6379 +tls-cert-file /tmp/data/tls.crt +tls-key-file /tmp/data/tls.key +tls-ca-cert-file /tmp/data/ca.crt +#tls-auth-clients no +tls-replication yes +%{endif} +appendonly yes +loglevel debug +requirepass default-pa55w0rd +masterauth default-pa55w0rd +protected-mode no +aclfile $ACL_FILE +" > $CONF_FILE + +echo "user default on sanitize-payload #338b13e36315b0a2114e0ea1b2157327e8310edb5faacbb9120b1f643ba1130b ~* &* +@all" > $ACL_FILE + +# start server +redis-server $CONF_FILE $FLAG $MASTER $MASTER_PORT +EOT + filename = "${path.module}/data/redis.sh" +} + diff --git a/bootstrap/sentinel/main.tf b/bootstrap/sentinel/main.tf new file mode 100644 index 0000000..77819fb --- /dev/null +++ b/bootstrap/sentinel/main.tf @@ -0,0 +1,92 @@ +provider "docker" {} +provider "random" {} + +resource "docker_image" "redis" { + name = "redis:7.2.3" + keep_locally = true +} + +resource "random_pet" "master-name" { + separator = "_" +} + +locals { + my-master-name = random_pet.master-name.id +} + +resource "docker_container" "redis-master" { + image = docker_image.redis.image_id + name = "redis-master" + hostname = "redis-master" + network_mode = "bridge" + command = ["/tmp/data/redis.sh"] + logs = true + + volumes { + host_path = "${path.cwd}/data" + container_path = "/tmp/data" + } + networks_advanced { + name = docker_network.private_network.name + aliases = ["redis-master"] + } +} + +resource "docker_container" "redis-replica" { + #attach = true + count = 2 + image = docker_image.redis.image_id + name = "redis-replica-${count.index}" + hostname = "redis-replica-${count.index}" + network_mode = "bridge" + command = ["/tmp/data/redis.sh", "--replicaof", "redis-master", "6379"] + #logs = true + + volumes { + host_path = "${path.cwd}/data" + container_path = "/tmp/data" + } + networks_advanced { + name = docker_network.private_network.name + aliases = ["redis-replica-${count.index}"] + } +} + +resource "docker_container" "redis-sentinels" { + #attach = true + count = 3 + image = docker_image.redis.image_id + name = "redis-sentinel-${count.index}" + hostname = "redis-sentinel-${count.index}" + network_mode = "bridge" + command = ["/tmp/data/sentinel.sh", "redis-master", "26379", "${local.my-master-name}"] + #logs = true + + volumes { + host_path = "${path.cwd}/data" + container_path = "/tmp/data" + } + networks_advanced { + name = docker_network.private_network.name + aliases = ["redis-sentinel-${count.index}"] + } + depends_on = [ + docker_container.redis-master + ] +} + +resource "docker_network" "private_network" { + name = "sentinel_network" + + ipam_driver = "default" + ipam_options = {} + ipv6 = false + options = {} + + ipam_config { + aux_address = {} + subnet = "192.168.200.0/28" + } +} + + diff --git a/bootstrap/sentinel/ouputs.tf b/bootstrap/sentinel/ouputs.tf new file mode 100644 index 0000000..05ad7b4 --- /dev/null +++ b/bootstrap/sentinel/ouputs.tf @@ -0,0 +1,12 @@ +output "s2" { + value = { for o in docker_container.redis-sentinels : o.hostname => o.network_data[0].ip_address } +} +output "env_var" { + value = flatten([for o in docker_container.redis-sentinels : o.network_data[0].ip_address]) +} +output "env_master" { + value = local.my-master-name +} +output "use-tls" { + value = var.use-tls +} diff --git a/bootstrap/sentinel/terraform.auto.tfvars b/bootstrap/sentinel/terraform.auto.tfvars new file mode 100644 index 0000000..c2df00e --- /dev/null +++ b/bootstrap/sentinel/terraform.auto.tfvars @@ -0,0 +1,2 @@ + +use-tls = true diff --git a/bootstrap/sentinel/terraform.tf b/bootstrap/sentinel/terraform.tf new file mode 100644 index 0000000..5b07a66 --- /dev/null +++ b/bootstrap/sentinel/terraform.tf @@ -0,0 +1,8 @@ +terraform { + required_providers { + docker = { + source = "kreuzwerker/docker" + version = "~> 3.0.1" + } + } +} diff --git a/bootstrap/sentinel/tls.tf b/bootstrap/sentinel/tls.tf new file mode 100644 index 0000000..ee21894 --- /dev/null +++ b/bootstrap/sentinel/tls.tf @@ -0,0 +1,86 @@ + +resource "tls_private_key" "ca_private_key" { + algorithm = "RSA" +} +# +resource "local_file" "ca_key" { + content = tls_private_key.ca_private_key.private_key_pem + filename = "${path.module}/data/private.key" +} + +resource "tls_self_signed_cert" "ca_cert" { + private_key_pem = tls_private_key.ca_private_key.private_key_pem + + is_ca_certificate = true + + subject { + country = "US" + common_name = "Root CA" + } + + validity_period_hours = 72 + + allowed_uses = [ + "digital_signature", + "cert_signing", + "crl_signing", + ] +} + +resource "local_file" "ca_cert" { + content = tls_self_signed_cert.ca_cert.cert_pem + filename = "${path.module}/data/ca.crt" +} + +# Create private key for server certificate +resource "tls_private_key" "internal" { + algorithm = "RSA" +} + +resource "local_file" "internal_key" { + content = tls_private_key.internal.private_key_pem + filename = "${path.module}/data/tls.key" +} + +# Create CSR for for server certificate +resource "tls_cert_request" "internal_csr" { + + private_key_pem = tls_private_key.internal.private_key_pem + + dns_names = ["*.*.*.*"] + ip_addresses = ["192.168.200.1", "192.168.200.2", "192.168.200.3", "192.168.200.4", "192.168.200.5", + "192.168.200.6", "192.168.200.7", "192.168.200.0"] + + subject { + country = "US" + organizational_unit = "Development" + } +} + +# Sign Seerver Certificate by Private CA +resource "tls_locally_signed_cert" "internal" { + // CSR by the development servers + cert_request_pem = tls_cert_request.internal_csr.cert_request_pem + // CA Private key + ca_private_key_pem = tls_private_key.ca_private_key.private_key_pem + // CA certificate + ca_cert_pem = tls_self_signed_cert.ca_cert.cert_pem + + validity_period_hours = 24 + + set_subject_key_id = true + + allowed_uses = [ + "digital_signature", + "key_encipherment", + "server_auth", + "client_auth", + ] +} + +resource "local_file" "internal_cert" { + content = tls_locally_signed_cert.internal.cert_pem + filename = "${path.module}/data/tls.crt" +} + + diff --git a/bootstrap/sentinel/variables.tf b/bootstrap/sentinel/variables.tf new file mode 100644 index 0000000..1ccb52c --- /dev/null +++ b/bootstrap/sentinel/variables.tf @@ -0,0 +1,6 @@ + +variable "use-tls" { + description = "Do you want TLS or not, true or false?" + type = bool +} + diff --git a/bootstrap/terraform/docker-compose.yml b/bootstrap/terraform/docker-compose.yml index edfad7d..269610d 100644 --- a/bootstrap/terraform/docker-compose.yml +++ b/bootstrap/terraform/docker-compose.yml @@ -14,7 +14,7 @@ services: networks: - redis restart: always - command: "redis-server --requirepass default-pa55w0rd --user us4rn4m3 on >user-pa55w0rd ~* allcommands" + command: "redis-server --requirepass default-pa55w0rd" ports: - "6379:6379" volumes: diff --git a/bootstrap/terraform/redis.tf b/bootstrap/terraform/redis.tf index 61cd177..967992f 100644 --- a/bootstrap/terraform/redis.tf +++ b/bootstrap/terraform/redis.tf @@ -8,7 +8,7 @@ resource "null_resource" "docker_compose_up" { // Running down at the beginning so terraform apply can be executed multiple times to pick up on latest docker-compose.yaml changes provisioner "local-exec" { - command = "docker-compose -f ./docker-compose.yml down && docker-compose -f ./docker-compose.yml up -d" + command = "docker compose -f ./docker-compose.yml down && docker compose -f ./docker-compose.yml up -d" when = create } } @@ -19,17 +19,18 @@ resource "null_resource" "docker_compose_down" { } provisioner "local-exec" { - command = "docker-compose -f ./docker-compose.yml down" + command = "docker compose -f ./docker-compose.yml down" when = destroy } } resource "local_file" "setup_environment_file" { filename = "local_environment_setup.sh" - content = <" + req.Password} var args []string @@ -145,13 +164,31 @@ func newUser(ctx context.Context, db radix.Client, username string, req dbplugin return errwrap.Wrapf("error unmarshalling REDIS rules in the creation statement JSON: {{err}}", err) } + // append the additional rules/permissions aclargs = append(aclargs, args...) + var response string + var replicaSets map[string]radix.ReplicaSet + var connType string - err = db.Do(ctx, radix.Cmd(&response, "ACL", aclargs...)) + connType, replicaSets, err = getReplicaSets(db) if err != nil { - return err + return errwrap.Wrapf(fmt.Sprintf("retrieving %s clients failed error: {{err}}", connType), err) + } + + for node, rs := range replicaSets { + for _, v := range getClientsFromRS(rs) { + + err = v.Do(ctx, radix.Cmd(&response, "ACL", aclargs...)) + if err != nil { + return errwrap.Wrapf(fmt.Sprintf("Response in %s newUser: %s for node %s, error: {{err}}", connType, node, response), err) + } + err = persistChange(ctx, v, mode) + if err != nil { + return errwrap.Wrapf(fmt.Sprintf("error persisting newUser on node %s: {{err}}", node), err) + } + } } return nil @@ -177,24 +214,52 @@ func (c *RedisDB) changeUserPassword(ctx context.Context, username, password str var response resp3.ArrayHeader mn := radix.Maybe{Rcv: &response} var redisErr resp3.SimpleError - err = db.Do(ctx, radix.Cmd(&mn, "ACL", "GETUSER", username)) - if errors.As(err, &redisErr) { - return fmt.Errorf("redis error returned: %s", redisErr.Error()) - } + + // check the user exists before attempting a password change + var replicaSets map[string]radix.ReplicaSet + var connType string + + connType, replicaSets, err = getReplicaSets(db) if err != nil { - return fmt.Errorf("reset of passwords for user %s failed in changeUserPassword: %w", username, err) + return errwrap.Wrapf(fmt.Sprintf("retrieving %s clients failed error: {{err}}", connType), err) } + for node, rs := range replicaSets { + for _, v := range getClientsFromRS(rs) { - if mn.Null { - return fmt.Errorf("changeUserPassword for user %s failed, user not found!", username) - } + err = v.Do(ctx, radix.Cmd(&mn, "ACL", "GETUSER", username)) + if errors.As(err, &redisErr) { + return err + } + + if err != nil { + return fmt.Errorf("reset of passwords for user %s failed in changeUserPassword on %s node %s: %w", username, connType, node, err) + } + if mn.Null { + return fmt.Errorf("changeUserPassword for user %s failed on %s node, %s, user not found!", username, connType, node) + } + } + } + // go ahead an change the password var sresponse string - err = db.Do(ctx, radix.Cmd(&sresponse, "ACL", "SETUSER", username, "RESETPASS", ">"+password)) + + connType, replicaSets, err = getReplicaSets(db) if err != nil { - return err + return errwrap.Wrapf(fmt.Sprintf("retrieving %s clients failed error: {{err}}", connType), err) + } + for node, rs := range replicaSets { + for _, v := range getClientsFromRS(rs) { + err = v.Do(ctx, radix.Cmd(&sresponse, "ACL", "SETUSER", username, "RESETPASS", ">"+password)) + if err != nil { + return errwrap.Wrapf(fmt.Sprintf("cluster reset of password for user %s on node %s failed, REDIS response %s, error, {{err}}", username, node, sresponse), err) + } + err = persistChange(ctx, v, c.Persistence) + if err != nil { + return errwrap.Wrapf(fmt.Sprintf("error persisting changeUserPassword on node %s: {{err}}", node), err) + } + } } return nil @@ -221,14 +286,135 @@ func computeTimeout(ctx context.Context) (timeout time.Duration) { return defaultTimeout } -func (c *RedisDB) getConnection(ctx context.Context) (radix.Client, error) { +func (c *RedisDB) getConnection(ctx context.Context) (radix.MultiClient, error) { db, err := c.Connection(ctx) if err != nil { return nil, err } - return db.(radix.Client), nil + return db.(radix.MultiClient), nil +} + +func (c *RedisDB) getPersistenceMode() string { + return c.Persistence } func (c *RedisDB) Type() (string, error) { return redisTypeName, nil } + +func (c *RedisDB) Close() error { + return nil +} + +// Get a Dialer +func (c *redisDBConnectionProducer) GetDialer(username, password string) (dialer radix.Dialer, err error) { + if c.TLS { + rootCAs := x509.NewCertPool() + ok := rootCAs.AppendCertsFromPEM([]byte(c.CACert)) + if !ok { + return radix.Dialer{}, fmt.Errorf("failed to parse root certificate") + } + // Mutual TLS required (client cert) + var cert tls.Certificate + if len(c.TLSCert) != 0 { + cert, err = tls.X509KeyPair([]byte(c.TLSCert), []byte(c.TLSKey)) + if err != nil { + return radix.Dialer{}, fmt.Errorf("failed to create key pair from tls_cert and tls_key parameters: %w", err) + } + } + dialer = radix.Dialer{ + AuthUser: username, + AuthPass: password, + NetDialer: &tls.Dialer{ + Config: &tls.Config{ + RootCAs: rootCAs, + Certificates: []tls.Certificate{cert}, + InsecureSkipVerify: c.InsecureTLS, + }, + }, + } + } else { + dialer = radix.Dialer{ + AuthUser: username, + AuthPass: password, + } + } + return dialer, nil +} + +func checkPersistence(ctx context.Context, client radix.MultiClient) error { + var replicaSets map[string]radix.ReplicaSet + var connType string + + connType, replicaSets, err := getReplicaSets(client) + if err != nil { + return fmt.Errorf("retrieving %s clients failed error: %w", connType, err) + } + + var response []string + mb := radix.Maybe{Rcv: &response} + + for _, rs := range replicaSets { + for _, v := range getClientsFromRS(rs) { + err = v.Do(ctx, radix.Cmd(&mb, "CONFIG", "GET", "ACLFILE")) + if err != nil { + return err + } else if mb.Null { + return fmt.Errorf("Error geting ACLFILE config setting") + } else { + if len(response[1]) == 0 { + return fmt.Errorf("ACL file not set on REDIS node %q, persistence not possible.", v.Addr().String()) + } + } + } + } + return nil +} + +func persistChange(ctx context.Context, client radix.Client, pmode string) error { + var response string + var err error + switch pmode { + case "REWRITE": + err = client.Do(ctx, radix.Cmd(&response, "CONFIG", "REWRITE")) + case "ACLFILE": + err = client.Do(ctx, radix.Cmd(&response, "ACL", "SAVE")) + } + if err != nil { + return errwrap.Wrapf(fmt.Sprintf("error from persistChange() response: %s, error: {{err}}", response), err) + } + + return nil +} + +func getClientsFromRS(rs radix.ReplicaSet) []radix.Client { + c := []radix.Client{} + if rs.Primary != nil { + c = append(c, rs.Primary) + } + for s := range rs.Secondaries { + c = append(c, rs.Secondaries[s]) + } + return c +} + +func getReplicaSets(client radix.MultiClient) (connType string, replicaSets map[string]radix.ReplicaSet, err error) { + switch client.(type) { + + case *radix.Sentinel: + replicaSets, err = client.(*radix.Sentinel).Clients() + connType = "Sentinel" + + case radix.MultiClient: + replicaSets, err = client.Clients() + connType = "MultiClient" + + case *radix.Cluster: + replicaSets, err = client.(*radix.Cluster).Clients() + connType = "Cluster" + + default: + err = fmt.Errorf("Unsupported client type passed to getReplicaSets") + } + return connType, replicaSets, err +} diff --git a/redis_test.go b/redis_test.go index 9d6a2c5..c2425a0 100644 --- a/redis_test.go +++ b/redis_test.go @@ -5,10 +5,9 @@ package redis import ( "context" - "crypto/tls" - "crypto/x509" "fmt" "os" + "strconv" "strings" "testing" "time" @@ -19,28 +18,56 @@ import ( dc "github.com/ory/dockertest/v3/docker" ) -var pre6dot5 = false // check for Pre 6.5.0 Redis - const ( - defaultUsername = "default" - defaultPassword = "default-pa55w0rd" - adminUsername = "Administrator" - adminPassword = "password" - aclCat = "+@admin" - testRedisRole = `["%s"]` - testRedisGroup = `["+@all"]` - testRedisRoleAndGroup = `["%s"]` + defaultUsername = "default" + defaultPassword = "default-pa55w0rd" + adminUsername = "Administrator" + adminPassword = "password" + sentinelUsername = defaultUsername + sentinelPassword = defaultPassword + aclCat = "+@admin" + testRedisRole = `["%s"]` + testRedisGroup = `["+@all"]` + testRedisRole3 = `["%s", "%s", "%s"]` ) -var redisTls = false +var ( + redisTls = false + redis_host = "" + redis_port = 0 + redis_secondaries = "" + redis_cluster_hosts = "" + redis_sentinel_hosts = "" + redis_sentinel_master_name = "" + persistence_mode = "" +) func prepareRedisTestContainer(t *testing.T) (func(), string, int) { if os.Getenv("TEST_REDIS_TLS") != "" { redisTls = true } - if os.Getenv("TEST_REDIS_HOST") != "" { - return func() {}, os.Getenv("TEST_REDIS_HOST"), 6379 + if host := os.Getenv("TEST_REDIS_PRIMARY_HOST"); host != "" { + redis_secondaries = os.Getenv("TEST_REDIS_SECONDARIES") + port, err := strconv.Atoi(os.Getenv("TEST_REDIS_PRIMARY_PORT")) + if err != nil { + port = 6379 + } + return func() {}, host, port + } + if env := os.Getenv("TEST_REDIS_CLUSTER"); env != "" { + redis_cluster_hosts = env + return func() {}, env, -1 + } + + if env := os.Getenv("TEST_REDIS_SENTINELS"); env != "" { + redis_sentinel_hosts = env + env = os.Getenv("TEST_REDIS_SENTINEL_MASTER_NAME") + if env != "" { + redis_sentinel_master_name = env + } + return func() {}, env, -2 } + // redver should match a redis repository tag. Default to latest. redver := os.Getenv("REDIS_VERSION") if redver == "" { @@ -100,44 +127,51 @@ func prepareRedisTestContainer(t *testing.T) (func(), string, int) { func TestDriver(t *testing.T) { var err error - var caCert []byte + var cleanup func() + if os.Getenv("TEST_REDIS_TLS") != "" { caCertFile := os.Getenv("CA_CERT_FILE") - caCert, err = os.ReadFile(caCertFile) + _, err = os.ReadFile(caCertFile) if err != nil { t.Fatal(fmt.Errorf("unable to read CA_CERT_FILE at %v: %w", caCertFile, err)) } } - // Spin up redis - cleanup, host, port := prepareRedisTestContainer(t) + // Spin up container Redis or process environment variables to connect to test Redis instances + cleanup, redis_host, redis_port = prepareRedisTestContainer(t) defer cleanup() - err = createUser(host, port, redisTls, caCert, defaultUsername, defaultPassword, "Administrator", "password", - aclCat) + err, persistence_mode = checkPersistenceMode(defaultUsername, defaultPassword) + if err != nil { + t.Log("Check for ACLSAVE persistence mode did not pass.") + persistence_mode = "None" + } + + err = createUser(defaultUsername, defaultPassword, "Administrator", "password", aclCat) if err != nil { t.Fatalf("Failed to create Administrator user using 'default' user: %s", err) } - err = createUser(host, port, redisTls, caCert, adminUsername, adminPassword, "rotate-root", "rotate-rootpassword", - aclCat) + err = createUser(adminUsername, adminPassword, "rotate-root", "rotate-rootpassword", aclCat) if err != nil { t.Fatalf("Failed to create rotate-root test user: %s", err) } - err = createUser(host, port, redisTls, caCert, adminUsername, adminPassword, "vault-edu", "password", - aclCat) + err = createUser(adminUsername, adminPassword, "vault-edu", "password", aclCat) if err != nil { t.Fatalf("Failed to create vault-edu test user: %s", err) } - t.Run("Init", func(t *testing.T) { testRedisDBInitialize_NoTLS(t, host, port) }) - t.Run("Init", func(t *testing.T) { testRedisDBInitialize_TLS(t, host, port) }) - t.Run("Create/Revoke", func(t *testing.T) { testRedisDBCreateUser(t, host, port) }) - t.Run("Create/Revoke", func(t *testing.T) { testRedisDBCreateUser_DefaultRule(t, host, port) }) - t.Run("Create/Revoke", func(t *testing.T) { testRedisDBCreateUser_plusRole(t, host, port) }) - t.Run("Create/Revoke", func(t *testing.T) { testRedisDBCreateUser_groupOnly(t, host, port) }) - t.Run("Create/Revoke", func(t *testing.T) { testRedisDBCreateUser_roleAndGroup(t, host, port) }) - t.Run("Rotate", func(t *testing.T) { testRedisDBRotateRootCredentials(t, host, port) }) - t.Run("Creds", func(t *testing.T) { testRedisDBSetCredentials(t, host, port) }) + t.Run("Init", func(t *testing.T) { testRedisDBInitialize_NoTLS(t) }) + t.Run("Init", func(t *testing.T) { testRedisDBInitialize_persistence(t) }) + t.Run("Init", func(t *testing.T) { testRedisDBInitialize_TLS(t) }) + t.Run("Create/Revoke", func(t *testing.T) { testRedisDBCreateUser(t) }) + t.Run("Create/Revoke", func(t *testing.T) { testRedisDBCreateUser_DefaultRule(t) }) + t.Run("Create/Revoke", func(t *testing.T) { testRedisDBCreateUser_plusRole(t) }) + t.Run("Create/Revoke", func(t *testing.T) { testRedisDBCreateUser_groupOnly(t) }) + t.Run("Create/Revoke", func(t *testing.T) { testRedisDBCreateUser_roleAndSelector(t) }) + t.Run("Create/Revoke", func(t *testing.T) { testRedisDBCreateUser_persistAclFile(t) }) + // t.Run("Create/Revoke", func(t *testing.T) { testRedisDBCreate_persistConfig(t, host, port) }) + t.Run("Rotate", func(t *testing.T) { testRedisDBRotateRootCredentials(t) }) + t.Run("Creds", func(t *testing.T) { testRedisDBSetCredentials(t) }) t.Run("Secret", func(t *testing.T) { testConnectionProducerSecretValues(t) }) t.Run("TimeoutCalc", func(t *testing.T) { testComputeTimeout(t) }) } @@ -165,78 +199,103 @@ func setupRedisDBInitialize(t *testing.T, connectionDetails map[string]interface return nil } -func testRedisDBInitialize_NoTLS(t *testing.T, host string, port int) { +func testRedisDBInitialize_NoTLS(t *testing.T) { if redisTls { t.Skip("skipping plain text Init() test in TLS mode") } t.Log("Testing plain text Init()") - connectionDetails := map[string]interface{}{ - "host": host, - "port": port, - "username": adminUsername, - "password": adminPassword, - } + connectionDetails := make(map[string]interface{}) + + setupParameters(&connectionDetails, "", "", "") + err := setupRedisDBInitialize(t, connectionDetails) if err != nil { t.Fatalf("Testing Init() failed: error: %s", err) } } -func testRedisDBInitialize_TLS(t *testing.T, host string, port int) { +func testRedisDBInitialize_TLS(t *testing.T) { if !redisTls { t.Skip("skipping TLS Init() test in plain text mode") } - CACertFile := os.Getenv("CA_CERT_FILE") - CACert, err := os.ReadFile(CACertFile) + t.Log("Testing TLS Init()") + + connectionDetails := make(map[string]interface{}) + + setupParameters(&connectionDetails, "", "", "") + + if err := setupCertificates(&connectionDetails); err != nil { + t.Fatal(fmt.Errorf("Issue encounted processing X509 inputs: %w", err)) + } + err := setupRedisDBInitialize(t, connectionDetails) if err != nil { - t.Fatal(fmt.Errorf("unable to read CA_CERT_FILE at %v: %w", CACertFile, err)) + t.Fatalf("Testing TLS Init() failed: error: %s", err) } +} - t.Log("Testing TLS Init()") +func testRedisDBInitialize_persistence(t *testing.T) { + if redisTls { + t.Skip("skipping plain text Init() with persistence_mode test in TLS mode") + } + + t.Log("Testing plain text Init() with persistence_mode") - connectionDetails := map[string]interface{}{ - "host": host, - "port": port, - "username": adminUsername, - "password": adminPassword, - "tls": true, - "ca_cert": CACert, - "insecure_tls": true, + connectionDetails := make(map[string]interface{}) + + setupParameters(&connectionDetails, "", "", "garbage") + + err := setupRedisDBInitialize(t, connectionDetails) + + if err == nil { + t.Fatalf("Testing Init() should have failed as the perstence_mode is garbage.") } - err = setupRedisDBInitialize(t, connectionDetails) + connectionDetails = make(map[string]interface{}) + + setupParameters(&connectionDetails, "", "", "rewrite") + + err = setupRedisDBInitialize(t, connectionDetails) if err != nil { - t.Fatalf("Testing TLS Init() failed: error: %s", err) + t.Fatalf("Testing Init() with perstence_mode rewrite failed: %s.", err) + } + + connectionDetails = make(map[string]interface{}) + + setupParameters(&connectionDetails, "", "", "aclfile") + + err = setupRedisDBInitialize(t, connectionDetails) + + if persistence_mode == "None" && err == nil { + t.Fatalf("Testing Init() with persistence_node detected as \"None\" should have failed but did not.") + } + + if err != nil && persistence_mode == "ACLFILE" { + t.Fatalf("Testing Init() with perstence_mode aclfile failed: %s", err) } } -func testRedisDBCreateUser(t *testing.T, address string, port int) { +func testRedisDBCreateUser(t *testing.T) { if os.Getenv("VAULT_ACC") == "" { t.SkipNow() } t.Log("Testing CreateUser()") - connectionDetails := map[string]interface{}{ - "host": address, - "port": port, - "username": adminUsername, - "password": adminPassword, + var rule []string + // if it is a cluster then these two rules are needed for the user to be able to connect. + if len(redis_cluster_hosts) != 0 { + rule = []string{`["+readonly", "+cluster"]`} } - if redisTls { - CACertFile := os.Getenv("CA_CERT_FILE") - CACert, err := os.ReadFile(CACertFile) - if err != nil { - t.Fatal(fmt.Errorf("unable to read CA_CERT_FILE at %v: %w", CACertFile, err)) - } + connectionDetails := make(map[string]interface{}) + + setupParameters(&connectionDetails, "", "", "") - connectionDetails["tls"] = true - connectionDetails["ca_cert"] = CACert - connectionDetails["insecure_tls"] = true + if err := setupCertificates(&connectionDetails); err != nil { + t.Fatal(fmt.Errorf("Issue encounted processing X509 inputs: %w", err)) } initReq := dbplugin.InitializeRequest{ @@ -262,7 +321,7 @@ func testRedisDBCreateUser(t *testing.T, address string, port int) { RoleName: "test", }, Statements: dbplugin.Statements{ - Commands: []string{}, + Commands: rule, }, Password: password, Expiration: time.Now().Add(time.Minute), @@ -275,40 +334,29 @@ func testRedisDBCreateUser(t *testing.T, address string, port int) { db.Close() - if err := checkCredsExist(t, userResp.Username, password, address, port); err != nil { + if err := checkCredsExist(t, userResp.Username, password); err != nil { t.Fatalf("Could not connect with new credentials: %s", err) } - err = revokeUser(t, userResp.Username, address, port) + err = revokeUser(t, userResp.Username) if err != nil { t.Fatalf("Could not revoke user: %s", userResp.Username) } } -func checkCredsExist(t *testing.T, username, password, address string, port int) error { +func checkCredsExist(t *testing.T, username, password string) error { if os.Getenv("VAULT_ACC") == "" { t.SkipNow() } t.Log("Testing checkCredsExist()") - connectionDetails := map[string]interface{}{ - "host": address, - "port": port, - "username": username, - "password": password, - } + connectionDetails := make(map[string]interface{}) - if redisTls { - CACertFile := os.Getenv("CA_CERT_FILE") - CACert, err := os.ReadFile(CACertFile) - if err != nil { - t.Fatal(fmt.Errorf("unable to read CA_CERT_FILE at %v: %w", CACertFile, err)) - } + setupParameters(&connectionDetails, username, password, "") - connectionDetails["tls"] = true - connectionDetails["ca_cert"] = CACert - connectionDetails["insecure_tls"] = true + if err := setupCertificates(&connectionDetails); err != nil { + t.Fatal(fmt.Errorf("Issue encounted processing X509 inputs: %w", err)) } initReq := dbplugin.InitializeRequest{ @@ -317,6 +365,7 @@ func checkCredsExist(t *testing.T, username, password, address string, port int) } db := new() + _, err := db.Initialize(context.Background(), initReq) if err != nil { t.Fatalf("err: %s", err) @@ -329,30 +378,19 @@ func checkCredsExist(t *testing.T, username, password, address string, port int) return nil } -func checkRuleAllowed(t *testing.T, username, password, address string, port int, cmd string, rules []string) error { +func checkRuleAllowed(t *testing.T, username, password, cmd string, rules []string) error { if os.Getenv("VAULT_ACC") == "" { t.SkipNow() } t.Log("Testing checkRuleAllowed()") - connectionDetails := map[string]interface{}{ - "host": address, - "port": port, - "username": username, - "password": password, - } + connectionDetails := make(map[string]interface{}) - if redisTls { - CACertFile := os.Getenv("CA_CERT_FILE") - CACert, err := os.ReadFile(CACertFile) - if err != nil { - t.Fatal(fmt.Errorf("unable to read CA_CERT_FILE at %v: %w", CACertFile, err)) - } + setupParameters(&connectionDetails, username, password, "") - connectionDetails["tls"] = true - connectionDetails["ca_cert"] = CACert - connectionDetails["insecure_tls"] = true + if err := setupCertificates(&connectionDetails); err != nil { + t.Fatal(fmt.Errorf("Issue encounted processing X509 inputs: %w", err)) } initReq := dbplugin.InitializeRequest{ @@ -371,34 +409,26 @@ func checkRuleAllowed(t *testing.T, username, password, address string, port int } var response string err = db.client.Do(context.Background(), radix.Cmd(&response, cmd, rules...)) + if err != nil { + return fmt.Errorf("Response in checkRules for %s %w", response, err) + } return err } -func revokeUser(t *testing.T, username, address string, port int) error { +func revokeUser(t *testing.T, username string) error { if os.Getenv("VAULT_ACC") == "" { t.SkipNow() } t.Log("Testing RevokeUser()") - connectionDetails := map[string]interface{}{ - "host": address, - "port": port, - "username": adminUsername, - "password": adminPassword, - } + connectionDetails := make(map[string]interface{}) - if redisTls { - CACertFile := os.Getenv("CA_CERT_FILE") - CACert, err := os.ReadFile(CACertFile) - if err != nil { - t.Fatal(fmt.Errorf("unable to read CA_CERT_FILE at %v: %w", CACertFile, err)) - } + setupParameters(&connectionDetails, "", "", "") - connectionDetails["tls"] = true - connectionDetails["ca_cert"] = CACert - connectionDetails["insecure_tls"] = true + if err := setupCertificates(&connectionDetails); err != nil { + t.Fatal(fmt.Errorf("Issue encounted processing X509 inputs: %w", err)) } initReq := dbplugin.InitializeRequest{ @@ -425,30 +455,27 @@ func revokeUser(t *testing.T, username, address string, port int) error { return nil } -func testRedisDBCreateUser_DefaultRule(t *testing.T, address string, port int) { +func testRedisDBCreateUser_DefaultRule(t *testing.T) { if os.Getenv("VAULT_ACC") == "" { t.SkipNow() } t.Log("Testing CreateUser_DefaultRule()") - connectionDetails := map[string]interface{}{ - "host": address, - "port": port, - "username": adminUsername, - "password": adminPassword, + var rules []string + // cluster user needs additional permissions. + if len(redis_cluster_hosts) != 0 { + rules = []string{`["~foo", "+@read", "+readonly", "+cluster"]`} + } else { + rules = []string{`["~foo", "+@read"]`} } - if redisTls { - CACertFile := os.Getenv("CA_CERT_FILE") - CACert, err := os.ReadFile(CACertFile) - if err != nil { - t.Fatal(fmt.Errorf("unable to read CA_CERT_FILE at %v: %w", CACertFile, err)) - } + connectionDetails := make(map[string]interface{}) - connectionDetails["tls"] = true - connectionDetails["ca_cert"] = CACert - connectionDetails["insecure_tls"] = true + setupParameters(&connectionDetails, "", "", "") + + if err := setupCertificates(&connectionDetails); err != nil { + t.Fatal(fmt.Errorf("Issue encounted processing X509 inputs: %w", err)) } initReq := dbplugin.InitializeRequest{ @@ -475,10 +502,10 @@ func testRedisDBCreateUser_DefaultRule(t *testing.T, address string, port int) { RoleName: username, }, Statements: dbplugin.Statements{ - Commands: []string{}, + Commands: rules, }, Password: password, - Expiration: time.Now().Add(time.Minute), + Expiration: time.Now().Add(time.Minute * 5), } userResp, err := db.NewUser(context.Background(), createReq) @@ -486,52 +513,40 @@ func testRedisDBCreateUser_DefaultRule(t *testing.T, address string, port int) { t.Fatalf("err: %s", err) } - if err := checkCredsExist(t, userResp.Username, password, address, port); err != nil { + if err := checkCredsExist(t, userResp.Username, password); err != nil { t.Fatalf("Could not connect with new credentials: %s", err) } - rules := []string{"foo"} - if err := checkRuleAllowed(t, userResp.Username, password, address, port, "get", rules); err != nil { - t.Fatalf("get failed with +@read rule: %s", err) + params := []string{"foo"} + if err := checkRuleAllowed(t, userResp.Username, password, "get", params); err != nil { + t.Fatalf("get failed for user %s with +@read rule: %s", userResp.Username, err) } - rules = []string{"foo", "bar"} - if err = checkRuleAllowed(t, userResp.Username, password, address, port, "set", rules); err == nil { - t.Fatalf("set did not fail with +@read rule: %s", err) + params = []string{"foo", "bar"} + if err = checkRuleAllowed(t, userResp.Username, password, "set", params); err == nil { + t.Fatalf("set did not fail user %s with +@read rule: %s", userResp.Username, err) } - err = revokeUser(t, userResp.Username, address, port) + err = revokeUser(t, userResp.Username) if err != nil { - t.Fatalf("Could not revoke user: %s", username) + t.Fatalf("Could not revoke user: %s", userResp.Username) } db.Close() } -func testRedisDBCreateUser_plusRole(t *testing.T, address string, port int) { +func testRedisDBCreateUser_plusRole(t *testing.T) { if os.Getenv("VAULT_ACC") == "" { t.SkipNow() } t.Log("Testing CreateUser_plusRole()") - connectionDetails := map[string]interface{}{ - "host": address, - "port": port, - "username": adminUsername, - "password": adminPassword, - "protocol_version": 4, - } + connectionDetails := make(map[string]interface{}) - if redisTls { - CACertFile := os.Getenv("CA_CERT_FILE") - CACert, err := os.ReadFile(CACertFile) - if err != nil { - t.Fatal(fmt.Errorf("unable to read CA_CERT_FILE at %v: %w", CACertFile, err)) - } + setupParameters(&connectionDetails, "", "", "") - connectionDetails["tls"] = true - connectionDetails["ca_cert"] = CACert - connectionDetails["insecure_tls"] = true + if err := setupCertificates(&connectionDetails); err != nil { + t.Fatal(fmt.Errorf("Issue encounted processing X509 inputs: %w", err)) } initReq := dbplugin.InitializeRequest{ @@ -557,7 +572,7 @@ func testRedisDBCreateUser_plusRole(t *testing.T, address string, port int) { RoleName: "test", }, Statements: dbplugin.Statements{ - Commands: []string{fmt.Sprintf(testRedisRole, aclCat)}, + Commands: []string{fmt.Sprintf(testRedisRole, "+@all")}, }, Password: password, Expiration: time.Now().Add(time.Minute), @@ -570,46 +585,97 @@ func testRedisDBCreateUser_plusRole(t *testing.T, address string, port int) { db.Close() - if err := checkCredsExist(t, userResp.Username, password, address, port); err != nil { + if err := checkCredsExist(t, userResp.Username, password); err != nil { t.Fatalf("Could not connect with new credentials: %s", err) } - err = revokeUser(t, userResp.Username, address, port) + err = revokeUser(t, userResp.Username) if err != nil { t.Fatalf("Could not revoke user: %s", userResp.Username) } } -// g1 & g2 must exist in the database. -func testRedisDBCreateUser_groupOnly(t *testing.T, address string, port int) { +/* [TODO] groupOnly hang over from Couchbase */ +func testRedisDBCreateUser_groupOnly(t *testing.T) { if os.Getenv("VAULT_ACC") == "" { t.SkipNow() } - if pre6dot5 { - t.Log("Skipping as groups are not supported pre6.5.0") + connectionDetails := make(map[string]interface{}) + + setupParameters(&connectionDetails, "", "", "") + + if err := setupCertificates(&connectionDetails); err != nil { + t.Fatal(fmt.Errorf("Issue encounted processing X509 inputs: %w", err)) + } + + initReq := dbplugin.InitializeRequest{ + Config: connectionDetails, + VerifyConnection: true, + } + + db := new() + _, err := db.Initialize(context.Background(), initReq) + if err != nil { + t.Fatalf("err: %s", err) + } + + if !db.Initialized { + t.Fatal("Database should be initialized") + } + + password := "y8fva_sdVA3rasf" + + createReq := dbplugin.NewUserRequest{ + UsernameConfig: dbplugin.UsernameMetadata{ + DisplayName: "test", + RoleName: "test", + }, + Statements: dbplugin.Statements{ + Commands: []string{fmt.Sprintf(testRedisGroup)}, + }, + Password: password, + Expiration: time.Now().Add(time.Minute), + } + + userResp, err := db.NewUser(context.Background(), createReq) + if err != nil { + t.Fatalf("err: %s", err) + } + + db.Close() + + if err := checkCredsExist(t, userResp.Username, password); err != nil { + t.Fatalf("Could not connect with new credentials: %s", err) + } + + err = revokeUser(t, userResp.Username) + if err != nil { + t.Fatalf("Could not revoke user: %s", userResp.Username) + } +} + +func testRedisDBCreateUser_roleAndSelector(t *testing.T) { + if os.Getenv("VAULT_ACC") == "" { t.SkipNow() } - t.Log("Testing CreateUser_groupOnly()") - connectionDetails := map[string]interface{}{ - "host": address, - "port": port, - "username": adminUsername, - "password": adminPassword, - "protocol_version": 4, + t.Log("Testing CreateUser() with selector rule") + + var rules []string + // cluster user needs additional permissions. + if len(redis_cluster_hosts) != 0 { + rules = []string{`["+GET", "allkeys", "(+SET ~app1*)", "+readonly", "+cluster"]`} + } else { + rules = []string{`["+GET", "allkeys", "(+SET ~app1*)"]`} } - if redisTls { - CACertFile := os.Getenv("CA_CERT_FILE") - CACert, err := os.ReadFile(CACertFile) - if err != nil { - t.Fatal(fmt.Errorf("unable to read CA_CERT_FILE at %v: %w", CACertFile, err)) - } + connectionDetails := make(map[string]interface{}) - connectionDetails["tls"] = true - connectionDetails["ca_cert"] = CACert - connectionDetails["insecure_tls"] = true + setupParameters(&connectionDetails, "", "", "") + + if err := setupCertificates(&connectionDetails); err != nil { + t.Fatal(fmt.Errorf("Issue encounted processing X509 inputs: %w", err)) } initReq := dbplugin.InitializeRequest{ @@ -635,7 +701,7 @@ func testRedisDBCreateUser_groupOnly(t *testing.T, address string, port int) { RoleName: "test", }, Statements: dbplugin.Statements{ - Commands: []string{fmt.Sprintf(testRedisGroup)}, + Commands: rules, }, Password: password, Expiration: time.Now().Add(time.Minute), @@ -648,45 +714,33 @@ func testRedisDBCreateUser_groupOnly(t *testing.T, address string, port int) { db.Close() - if err := checkCredsExist(t, userResp.Username, password, address, port); err != nil { + if err := checkCredsExist(t, userResp.Username, password); err != nil { t.Fatalf("Could not connect with new credentials: %s", err) } - err = revokeUser(t, userResp.Username, address, port) + err = revokeUser(t, userResp.Username) if err != nil { t.Fatalf("Could not revoke user: %s", userResp.Username) } } -func testRedisDBCreateUser_roleAndGroup(t *testing.T, address string, port int) { +func testRedisDBCreateUser_persistAclFile(t *testing.T) { if os.Getenv("VAULT_ACC") == "" { t.SkipNow() } - if pre6dot5 { - t.Log("Skipping as groups are not supported pre6.5.0") - t.SkipNow() + if persistence_mode == "None" { + t.Skip("Skipping persist config as this REDIS installation is not configured to use an acl file.") } - t.Log("Testing CreateUser_roleAndGroup()") - connectionDetails := map[string]interface{}{ - "host": address, - "port": port, - "username": adminUsername, - "password": adminPassword, - "protocol_version": 4, - } + t.Log("Testing CreateUser_persist()") - if redisTls { - CACertFile := os.Getenv("CA_CERT_FILE") - CACert, err := os.ReadFile(CACertFile) - if err != nil { - t.Fatal(fmt.Errorf("unable to read CA_CERT_FILE at %v: %w", CACertFile, err)) - } + connectionDetails := make(map[string]interface{}) - connectionDetails["tls"] = true - connectionDetails["ca_cert"] = CACert - connectionDetails["insecure_tls"] = true + setupParameters(&connectionDetails, "", "", "aclfile") + + if err := setupCertificates(&connectionDetails); err != nil { + t.Fatal(fmt.Errorf("Issue encounted processing X509 inputs: %w", err)) } initReq := dbplugin.InitializeRequest{ @@ -712,7 +766,7 @@ func testRedisDBCreateUser_roleAndGroup(t *testing.T, address string, port int) RoleName: "test", }, Statements: dbplugin.Statements{ - Commands: []string{fmt.Sprintf(testRedisRoleAndGroup, aclCat)}, + Commands: []string{fmt.Sprintf(testRedisGroup)}, }, Password: password, Expiration: time.Now().Add(time.Minute), @@ -725,40 +779,29 @@ func testRedisDBCreateUser_roleAndGroup(t *testing.T, address string, port int) db.Close() - if err := checkCredsExist(t, userResp.Username, password, address, port); err != nil { + if err := checkCredsExist(t, userResp.Username, password); err != nil { t.Fatalf("Could not connect with new credentials: %s", err) } - err = revokeUser(t, userResp.Username, address, port) + err = revokeUser(t, userResp.Username) if err != nil { t.Fatalf("Could not revoke user: %s", userResp.Username) } } -func testRedisDBRotateRootCredentials(t *testing.T, address string, port int) { +func testRedisDBRotateRootCredentials(t *testing.T) { if os.Getenv("VAULT_ACC") == "" { t.SkipNow() } t.Log("Testing RotateRootCredentials()") - connectionDetails := map[string]interface{}{ - "host": address, - "port": port, - "username": "rotate-root", - "password": "rotate-rootpassword", - } + connectionDetails := make(map[string]interface{}) - if redisTls { - CACertFile := os.Getenv("CA_CERT_FILE") - CACert, err := os.ReadFile(CACertFile) - if err != nil { - t.Fatal(fmt.Errorf("unable to read CA_CERT_FILE at %v: %w", CACertFile, err)) - } + setupParameters(&connectionDetails, "rotate-root", "rotate-rootpassword", "") - connectionDetails["tls"] = true - connectionDetails["ca_cert"] = CACert - connectionDetails["insecure_tls"] = true + if err := setupCertificates(&connectionDetails); err != nil { + t.Fatal(fmt.Errorf("Issue encounted processing X509 inputs: %w", err)) } initReq := dbplugin.InitializeRequest{ @@ -791,33 +834,22 @@ func testRedisDBRotateRootCredentials(t *testing.T, address string, port int) { } // defer setting the password back in case the test fails. - defer doRedisDBSetCredentials(t, "rotate-root", "rotate-rootpassword", address, port) + defer doRedisDBSetCredentials(t, "rotate-root", "rotate-rootpassword") - if err := checkCredsExist(t, db.Username, "newpassword", address, port); err != nil { + if err := checkCredsExist(t, db.Username, "newpassword"); err != nil { t.Fatalf("Could not connect with new RotatedRootcredentials: %s", err) } } -func doRedisDBSetCredentials(t *testing.T, username, password, address string, port int) { +func doRedisDBSetCredentials(t *testing.T, username, password string) { t.Log("Testing SetCredentials()") - connectionDetails := map[string]interface{}{ - "host": address, - "port": port, - "username": adminUsername, - "password": adminPassword, - } + connectionDetails := make(map[string]interface{}) - if redisTls { - CACertFile := os.Getenv("CA_CERT_FILE") - CACert, err := os.ReadFile(CACertFile) - if err != nil { - t.Fatal(fmt.Errorf("unable to read CA_CERT_FILE at %v: %w", CACertFile, err)) - } + setupParameters(&connectionDetails, "", "", "") - connectionDetails["tls"] = true - connectionDetails["ca_cert"] = CACert - connectionDetails["insecure_tls"] = true + if err := setupCertificates(&connectionDetails); err != nil { + t.Fatal(fmt.Errorf("Issue encounted processing X509 inputs: %w", err)) } initReq := dbplugin.InitializeRequest{ @@ -864,17 +896,17 @@ func doRedisDBSetCredentials(t *testing.T, username, password, address string, p db.Close() - if err := checkCredsExist(t, username, password, address, port); err != nil { + if err := checkCredsExist(t, username, password); err != nil { t.Fatalf("Could not connect with rotated credentials: %s", err) } } -func testRedisDBSetCredentials(t *testing.T, address string, port int) { +func testRedisDBSetCredentials(t *testing.T) { if os.Getenv("VAULT_ACC") == "" { t.SkipNow() } - doRedisDBSetCredentials(t, "vault-edu", "password", address, port) + doRedisDBSetCredentials(t, "vault-edu", "password") } func testConnectionProducerSecretValues(t *testing.T) { @@ -903,57 +935,171 @@ func testComputeTimeout(t *testing.T) { } } -func createUser(hostname string, port int, redisTls bool, CACert []byte, adminuser, adminpassword, username, password, aclRule string) (err error) { - var poolConfig radix.PoolConfig +func checkPersistenceMode(adminUsername, adminPassword string) (err error, mode string) { + fmt.Printf("Checking the supported persistence mode.\n") - if redisTls { - rootCAs := x509.NewCertPool() - ok := rootCAs.AppendCertsFromPEM(CACert) - if !ok { - return fmt.Errorf("failed to parse root certificate") - } + connectionDetails := make(map[string]interface{}) - poolConfig = radix.PoolConfig{ - Dialer: radix.Dialer{ - AuthUser: adminuser, - AuthPass: adminpassword, - NetDialer: &tls.Dialer{ - Config: &tls.Config{ - RootCAs: rootCAs, - InsecureSkipVerify: true, - }, - }, - }, - } - } else { - poolConfig = radix.PoolConfig{ - Dialer: radix.Dialer{ - AuthUser: adminuser, - AuthPass: adminpassword, - }, - } + setupParameters(&connectionDetails, adminUsername, adminPassword, "") + + if err := setupCertificates(&connectionDetails); err != nil { + return fmt.Errorf("Issue encounted processing X509 inputs: %w", err), "" + } + + initReq := dbplugin.InitializeRequest{ + Config: connectionDetails, + VerifyConnection: false, // false as we don't want the built in persistence check } - addr := fmt.Sprintf("%s:%d", hostname, port) - client, err := poolConfig.New(context.Background(), "tcp", addr) + db := new() + _, err = db.Initialize(context.Background(), initReq) if err != nil { - return err + return fmt.Errorf("Failed to initialize database: %s", err), "" + } + + if !db.Initialized { + return fmt.Errorf("Database should be initialized"), "" + } + + ctx, _ := context.WithTimeout(context.Background(), 5000*time.Millisecond) + + client, err := db.getConnection(ctx) + if err != nil { + return err, "" + } + + err = checkPersistence(ctx, client.(radix.MultiClient)) + + db.Close() + + if err == nil { + return nil, "ACLFILE" + } + return err, "" +} + +func createUser(adminUsername, adminPassword, username, password, aclRule string) (err error) { + fmt.Printf("Creating test user %s\n", username) + + var cluster_rules []string + // extra rules needed to access cluster information + if len(redis_cluster_hosts) != 0 { + cluster_rules = []string{"+readonly", "+cluster"} + } + + connectionDetails := make(map[string]interface{}) + + setupParameters(&connectionDetails, adminUsername, adminPassword, "") + + if err := setupCertificates(&connectionDetails); err != nil { + return fmt.Errorf("Issue encounted processing X509 inputs: %w", err) + } + + initReq := dbplugin.InitializeRequest{ + Config: connectionDetails, + VerifyConnection: true, } + db := new() + _, err = db.Initialize(context.Background(), initReq) + if err != nil { + return fmt.Errorf("Failed to initialize database: %s", err) + } + + if !db.Initialized { + return fmt.Errorf("Database should be initialized") + } + + // setup REDIS command + aclargs := []string{"SETUSER", username, "ON", ">" + password, aclRule} + aclargs = append(aclargs, cluster_rules...) + var response string - err = client.Do(context.Background(), radix.Cmd(&response, "ACL", "SETUSER", username, "on", ">"+password, aclRule)) + var replicaSets map[string]radix.ReplicaSet + var connType string - fmt.Printf("Response in createUser: %s\n", response) + connType, replicaSets, err = getReplicaSets(db.client) if err != nil { - return err + return fmt.Errorf("retrieving %s clients failed error: %w", connType, err) } - if client != nil { - if err = client.Close(); err != nil { - return err + ctx, _ := context.WithTimeout(context.Background(), 5000*time.Millisecond) + + for node, rs := range replicaSets { + for _, v := range getClientsFromRS(rs) { + + err = v.Do(ctx, radix.Cmd(&response, "ACL", aclargs...)) + if err != nil { + return fmt.Errorf("Response in %s newUser: %s for node %s, error: %w", connType, node, response, err) + } } } return nil } + +func setupParameters(connectionDetails *map[string]interface{}, username, password, pmode string) { + (*connectionDetails)["primary_host"] = redis_host + (*connectionDetails)["primary_port"] = redis_port + (*connectionDetails)["secondaries"] = redis_secondaries + (*connectionDetails)["cluster"] = redis_cluster_hosts + (*connectionDetails)["sentinels"] = redis_sentinel_hosts + (*connectionDetails)["sentinel_master_name"] = redis_sentinel_master_name + if len(username) == 0 { + (*connectionDetails)["username"] = adminUsername + } else { + (*connectionDetails)["username"] = username + } + if len(password) == 0 { + (*connectionDetails)["password"] = adminPassword + } else { + (*connectionDetails)["password"] = password + } + (*connectionDetails)["sentinel_username"] = sentinelUsername + (*connectionDetails)["sentinel_password"] = sentinelPassword + if len(pmode) != 0 { + (*connectionDetails)["persistence_mode"] = pmode + } +} + +func setupCertificates(connectionDetails *map[string]interface{}) (err error) { + if !redisTls { + return nil + } + + var TLSCert, TLSKey []byte + + CACertFile := os.Getenv("CA_CERT_FILE") + CACert, err := os.ReadFile(CACertFile) + if err != nil { + return fmt.Errorf("unable to read CA_CERT_FILE at %v: %w", CACertFile, err) + } + + TLSCertFile := os.Getenv("TLS_CERT_FILE") + if TLSCertFile != "" { + TLSCert, err = os.ReadFile(TLSCertFile) + if err != nil { + return fmt.Errorf("unable to read TLS_CERT_FILE at %v: %w", TLSCertFile, err) + } + } + TLSKeyFile := os.Getenv("TLS_KEY_FILE") + if TLSKeyFile != "" { + TLSKey, err = os.ReadFile(TLSKeyFile) + if err != nil { + return fmt.Errorf("unable to read TLS_KEY_FILE at %v: %w", TLSKeyFile, err) + } + } + + if (len(TLSCert) != 0 && len(TLSKey) == 0) || + (len(TLSCert) == 0 && len(TLSKey) != 0) { + return fmt.Errorf("For mutual TLS both tls_cert and tls_key parameter must be set") + } + (*connectionDetails)["tls"] = true + (*connectionDetails)["ca_cert"] = CACert + (*connectionDetails)["insecure_tls"] = false + (*connectionDetails)["tls_cert"] = TLSCert + (*connectionDetails)["tls_key"] = TLSKey + + return nil +} diff --git a/tools/go.mod b/tools/go.mod index 51e4dad..5ab9a3b 100644 --- a/tools/go.mod +++ b/tools/go.mod @@ -4,10 +4,13 @@ go 1.19 require mvdan.cc/gofumpt v0.3.1 +require github.com/itchyny/gojq v0.12.15 + require ( github.com/google/go-cmp v0.5.7 // indirect + github.com/itchyny/timefmt-go v0.1.5 // indirect golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3 // indirect golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect - golang.org/x/sys v0.1.0 // indirect + golang.org/x/sys v0.18.0 // indirect golang.org/x/tools v0.1.10 // indirect ) diff --git a/tools/go.sum b/tools/go.sum index 620a709..0e0b689 100644 --- a/tools/go.sum +++ b/tools/go.sum @@ -1,6 +1,10 @@ github.com/frankban/quicktest v1.14.2 h1:SPb1KFFmM+ybpEjPUhCCkZOM5xlovT5UbrMvWnXyBns= github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o= github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= +github.com/itchyny/gojq v0.12.15 h1:WC1Nxbx4Ifw5U2oQWACYz32JK8G9qxNtHzrvW4KEcqI= +github.com/itchyny/gojq v0.12.15/go.mod h1:uWAHCbCIla1jiNxmeT5/B5mOjSdfkCq6p8vxWg+BM10= +github.com/itchyny/timefmt-go v0.1.5 h1:G0INE2la8S6ru/ZI5JecgyzbbJNs5lG1RcBqa7Jm6GE= +github.com/itchyny/timefmt-go v0.1.5/go.mod h1:nEP7L+2YmAbT2kZ2HfSs1d8Xtw9LY8D2stDBckWakZ8= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e h1:aoZm08cpOy4WuID//EZDgcC4zIxODThtZNPirFr42+A= @@ -9,8 +13,8 @@ golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3 h1:kQgndtyPBW/JIYERgdx golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/tools v0.1.10 h1:QjFRCZxdOhBJ/UNgnBZLbNV13DlbnK0quyivTnXJM20= golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/tools/tools.go b/tools/tools.go index 283ffd8..4429d6f 100644 --- a/tools/tools.go +++ b/tools/tools.go @@ -2,6 +2,7 @@ // SPDX-License-Identifier: MPL-2.0 //go:build tools +// +build tools // This file ensures tool dependencies are kept in sync. This is the // recommended way of doing this according to @@ -14,6 +15,8 @@ package tools //go:generate go install mvdan.cc/gofumpt +//go:generate go install github.com/itchyny/gojq/cmd/gojq import ( + _ "github.com/itchyny/gojq" _ "mvdan.cc/gofumpt" )