From 33d73420fc2f696dc4728e0aa6b3e45075cf8ae3 Mon Sep 17 00:00:00 2001 From: Maximilien Cuony Date: Tue, 13 May 2025 13:24:34 +0200 Subject: [PATCH] Certificates management --- .gitignore | 4 +- .../certificates-management/README.md | 131 ++++++++++ .../certificates-management/__init__.py | 0 .../certificates-management/apply.py | 51 ++++ .../certificates-management/ca_pool.py | 145 +++++++++++ .../certificates-management/cluster.py | 135 ++++++++++ .../certificates-management/dss-certs.py | 143 +++++++++++ .../certificates-management/init.py | 240 ++++++++++++++++++ .../certificates-management/nodes.py | 152 +++++++++++ .../certificates-management/utils.py | 51 ++++ 10 files changed, 1051 insertions(+), 1 deletion(-) create mode 100644 deploy/operations/certificates-management/README.md create mode 100644 deploy/operations/certificates-management/__init__.py create mode 100644 deploy/operations/certificates-management/apply.py create mode 100644 deploy/operations/certificates-management/ca_pool.py create mode 100644 deploy/operations/certificates-management/cluster.py create mode 100755 deploy/operations/certificates-management/dss-certs.py create mode 100644 deploy/operations/certificates-management/init.py create mode 100644 deploy/operations/certificates-management/nodes.py create mode 100644 deploy/operations/certificates-management/utils.py diff --git a/.gitignore b/.gitignore index a24e88084..3847bd4c5 100644 --- a/.gitignore +++ b/.gitignore @@ -64,6 +64,8 @@ build/cockroachdb.yaml build/values.yaml build/dss.yaml +deploy/operations/certificates-management/workspace/ + temp # Django stuff: @@ -131,4 +133,4 @@ go .vscode # terraform -.terraform* \ No newline at end of file +.terraform* diff --git a/deploy/operations/certificates-management/README.md b/deploy/operations/certificates-management/README.md new file mode 100644 index 000000000..726037c78 --- /dev/null +++ b/deploy/operations/certificates-management/README.md @@ -0,0 +1,131 @@ +# Certificates management + +## Introduction + +The `dss-certs.py` helps you manage the set of certificates used for your DSS deployment. + +Should this DSS beeing part of a pool, the script also provide some helpers to manage the set of CA certificates in the pool. + +To run the script, just run `./dss-certs.py`. The python script don't require any dependencies, just a recent version of python 3. + +## Quick start guide + +### Single DSS instance in minikube` + +* `./dss-certs.py --name test --cluster-context dss-local-cluster --namespace default init` +* `./dss-certs.py --name test --cluster-context dss-local-cluster --namespace default apply` + +### Pool of 3 DSS instances in minikube, in namespace `default`, `ns2` and `ns3` + +* Creation of the 3 DSS instances certificates +* `./dss-certs.py --name dss-instance-1 --cluster-context dss-local-cluster --namespace default init` +* `./dss-certs.py --name dss-instance-2 --cluster-context dss-local-cluster --namespace ns2 init` +* `./dss-certs.py --name dss-instance-1 --cluster-context dss-local-cluster --namespace ns3 init` +* Copy instance 2 and 3 CA certificates to the instance 1 +* `./dss-certs.py --name dss-instance-2 --cluster-context dss-local-cluster --namespace ns2 get-ca | ./dss-certs.py --name dss-instance-1 --cluster-context dss-local-cluster --namespace default add-pool-ca` +* `./dss-certs.py --name dss-instance-3 --cluster-context dss-local-cluster --namespace ns3 get-ca | ./dss-certs.py --name dss-instance-1 --cluster-context dss-local-cluster --namespace default add-pool-ca` +* Reuse instance compiled 1 CA and copy it to instance 2 and 3. +* `./dss-certs.py --name dss-instance-1 --cluster-context dss-local-cluster --namespace default get-pool-ca | ./dss-certs.py --name dss-instance-2 --cluster-context dss-local-cluster --namespace ns2 add-pool-ca` +* `./dss-certs.py --name dss-instance-1 --cluster-context dss-local-cluster --namespace default get-pool-ca | ./dss-certs.py --name dss-instance-3 --cluster-context dss-local-cluster --namespace ns3 add-pool-ca` +* Application of certificates in respective clusters +* `./dss-certs.py --name dss-instance-1 --cluster-context dss-local-cluster --namespace default apply` +* `./dss-certs.py --name dss-instance-2 --cluster-context dss-local-cluster --namespace ns2 apply` +* `./dss-certs.py --name dss-instance-3 --cluster-context dss-local-cluster --namespace ns3 apply` + +## Operations + +### Common parameters + +#### `--name` + +The name of your DSS instance, that should identify it in a unique way. Used as main identifier for the set of certificates and in certificates. + +Example: `dss-west-1` + +#### `--organization` + +The name of the organization managing the DSS Instance. Used in certificates generation. The combination of (name, organization) shall be unique in a cluster. + +Example: `Interuss` + +#### `--cluster-context` + +The kubernetes context the script should use. + +Example: `dss-local-cluster` + +#### `--namespace` + +The kubernetes namespace to use. + +Example: `default` + +#### `--nodes-count` + +The number of yugabyte nodes of your DSS instance. Default to `3`. + +### `init` + +Initializes the certificates for a new DSS instance including a CA, a client certificate and a certificate for each yugabyte node. + +### `apply` + +Apply the current set of certificates to the kubernetes cluster. Shall be ran after each modification of the certificates, like addition / removal of CA in the pool, new `nodes-count` parameter. + +### `regenerate-nodes` + +Generate missing nodes certificates. Useful if you want to add new nodes in your DSS Instance. Don't forget to set the `nodes-count` parameters. + +### `add-pool-ca` + +Add a CA certificate(s) of another(s) DSS Instance to the set of trusted certificates. +Existing certificates are not added again. + +You can set the file with certificate(s) with `--ca-file` or use stdin. + +Don't forget to use the `apply` command to update certificate on your kubernetes cluster. + +Examples: + +* `./dss-certs.py --name test --cluster-context dss-local-cluster --namespace default add-pool-ca < /tmp/new-dss-ca` +* `./dss-certs.py --name test --cluster-context dss-local-cluster --namespace default --ca-file /tmp/new-dss-ca add-pool-ca` +* `./dss-certs.py --name test --cluster-context dss-local-cluster --namespace default get-pool-ca | ./dss-certs.py --name test2 --cluster-context dss-local-cluster --namespace namespace2 add-pool-ca` + +### `remove-pool-ca` + +Remove CA certificate(s) of DSS Instance(s) from the set of trusted certificates. +Unknown certificates are not removed again. + +You can set the file with certificate(s) with `--ca-file`, use stdin or use `--ca-serial` to specify the serial / name of the certificate you want to remove. + +Don't forget to use the `apply` command to update certificate on your kubernetes cluster. + +Example: + +* `./dss-certs.py --name test --cluster-context dss-local-cluster --namespace default remove-pool-ca < /tmp/old-dss-ca` +* `./dss-certs.py --name test --cluster-context dss-local-cluster --namespace default --ca-file /tmp/old-dss-ca remove-pool-ca` +* `./dss-certs.py --name test --cluster-context dss-local-cluster --namespace default remove-pool-ca --ca-serial="SN=830ECFB0, O=generic-dss-organization, CN=CA.test"` +* `./dss-certs.py --name test --cluster-context dss-local-cluster --namespace default remove-pool-ca --ca-serial="830ECFB0` +* `./dss-certs.py --name test --cluster-context dss-local-cluster --namespace default remove-pool-ca --ca-serial="46548B7CC9699A7CFA54FF8FA85A619E830ECFB0` + +### `list-pool-ca` + +List the set of accepted CA certificates. + +Also display a 'hash' of CA serial, that you may use to compare other DSS Instances list of CA certificates easily. + +### `get-pool-ca` + +Return all CA certificate in the current pool. + +Can be used for debugging or to synchronize the set of CA certificates in a pool with others USS. + +### `get-ca` + +Return your own CA certificate . + +Display the compiled CA certificate. Can be used for debugging or to synchronize the set of CA certificates in a pool with others USS. + +### `destroy` + +Destroy a certificate set. Be careful, there are no way to undo the command. diff --git a/deploy/operations/certificates-management/__init__.py b/deploy/operations/certificates-management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/deploy/operations/certificates-management/apply.py b/deploy/operations/certificates-management/apply.py new file mode 100644 index 000000000..17e83e008 --- /dev/null +++ b/deploy/operations/certificates-management/apply.py @@ -0,0 +1,51 @@ +import subprocess +import os + +import logging +l = logging.getLogger(__name__) + +def do_apply(cluster): + + l.debug("Applying kubernetes configuration") + + l.debug(f"Creating namespace {cluster.namespace}") + + try: + subprocess.check_call( + ["kubectl", "create", "namespace", cluster.namespace, "--context", cluster.cluster_context], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + l.info(f"Created namespace {cluster.namespace}") + + except subprocess.CalledProcessError: # We do assume everything else works + l.debug(f"Namespace {cluster.namespace} already exists") + + for secret_name in ["yb-master-yugabyte-tls-cert", "yb-tserver-yugabyte-tls-cert", "yugabyte-tls-client-cert", "dss.public.certs"]: + + try: + subprocess.check_call( + ["kubectl", "delete", "secret", secret_name, "--namespace", cluster.namespace, "--context", cluster.cluster_context], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + l.info(f"Deleted old secret '{secret_name}'") + + except subprocess.CalledProcessError: # We do assume everything else works + l.debug(f"Secret '{secret_name}' not present on the cluster") + + for secret_name, folder in [ + ("yb-master-yugabyte-tls-cert", cluster.master_certs_dir), + ("yb-tserver-yugabyte-tls-cert", cluster.tserver_certs_dir), + ("yugabyte-tls-client-cert", cluster.client_certs_dir), + ("dss.public.certs", os.path.join("..", "..", "..", "build", "jwt-public-certs")), + ]: + + subprocess.check_call( + ["kubectl", "create", "secret", "generic", secret_name, "--namespace", cluster.namespace, "--context", cluster.cluster_context, "--from-file", folder], + stdout=subprocess.DEVNULL, + ) + + l.info(f"Created secret '{secret_name}'") diff --git a/deploy/operations/certificates-management/ca_pool.py b/deploy/operations/certificates-management/ca_pool.py new file mode 100644 index 000000000..bcaf35f79 --- /dev/null +++ b/deploy/operations/certificates-management/ca_pool.py @@ -0,0 +1,145 @@ +import base64 +import hashlib +import logging +import os +import re +import shutil +import tempfile + +from utils import get_cert_display_name, get_cert_serial + +l = logging.getLogger(__name__) + + +def build_pool_hash(cluster): + + CAs = [] + for f in os.listdir(cluster.ca_pool_dir): + + if f.endswith(".crt") and f != "ca.crt": + CAs.append(f.lower()) + + CAs = sorted(CAs) + + h = hashlib.sha256() + h.update((",".join(CAs)).encode("utf-8")) + + # Create an hash without special chars (replaced by 'Aa') + hashed = base64.b64encode(h.digest(), b"Aa").decode("utf-8") + + return f"{hashed[:5]}-{hashed[-10:-5]}" + + +def add_cas(cluster, certificate): + + folder = cluster.ca_pool_dir + + l.debug("Getting new CA metadata") + + with tempfile.NamedTemporaryFile(delete_on_close=False) as tf: + tf.write(certificate.encode("utf-8")) + tf.close() + + serial = get_cert_serial(tf.name) + name = get_cert_display_name(tf.name) + + filename = f"{serial}.crt" + + target_file = os.path.join(folder, filename) + + if os.path.exists(target_file): + l.info(f"CA {name} already present in the pool") + return + + l.info(f"Adding CA {name} in the pool") + + with open(target_file, "w") as f: + f.write(certificate) + + +def regenerate_ca_files(cluster): + + l.debug("Regenerating CA files from all CA in the pool") + + CAs = [] + for filename in os.listdir(cluster.ca_pool_dir): + + if filename.endswith(".crt") and filename != "ca.crt": + with open(os.path.join(cluster.ca_pool_dir, filename), "r") as f: + CAs.append(f.read()) + + CAs = sorted(CAs) + + with open(cluster.ca_pool_ca, "w") as f: + f.write("\n\n".join(CAs)) + + shutil.copy(cluster.ca_pool_ca, cluster.client_ca) + + for node_type in ["master", "tserver"]: + shutil.copy(cluster.ca_pool_ca, getattr(cluster, f"{node_type}_ca")) + + h = build_pool_hash(cluster) + + l.info(f"Regenerated CA files from the CA pool. Current pool hash: {h}") + + +def do_add_cas(cluster, certificates): + pattern = re.compile( + r"-----BEGIN CERTIFICATE-----\s*.+?\s*-----END CERTIFICATE-----", re.DOTALL + ) + for cert in pattern.findall(certificates): + add_cas(cluster, cert) + + regenerate_ca_files(cluster) + + +def do_remove_cas(cluster, certificates_or_serial): + pattern = re.compile( + r"-----BEGIN CERTIFICATE-----\s*.+?\s*-----END CERTIFICATE-----", re.DOTALL + ) + for cert in pattern.findall(certificates_or_serial): + with tempfile.NamedTemporaryFile(delete_on_close=False) as tf: + tf.write(cert.encode("utf-8")) + tf.close() + serial = get_cert_serial(tf.name) + name = get_cert_display_name(tf.name) + + filename = f"{serial}.crt" + + target = os.path.join(cluster.ca_pool_dir, filename) + + if os.path.isfile(target): + os.unlink(target) + l.info(f"Removed certificate {name}") + else: + l.info(f"Certificate {name} not present in pool") + + for filename in sorted(os.listdir(cluster.ca_pool_dir)): + if filename.endswith(".crt") and filename != "ca.crt": + + serial = get_cert_serial(os.path.join(cluster.ca_pool_dir, filename)) + name = get_cert_display_name(os.path.join(cluster.ca_pool_dir, filename)) + + if certificates_or_serial == name or certificates_or_serial == serial or f"SN={certificates_or_serial}, " in name or name.startswith(certificates_or_serial): + os.unlink(os.path.join(cluster.ca_pool_dir, filename)) + l.info(f"Removed certificate {name}") + + regenerate_ca_files(cluster) + +def do_get_ca(cluster): + with open(cluster.ca_cert_file, "r") as f: + print(f.read()) + +def do_get_pool_ca(cluster): + with open(cluster.ca_pool_ca, "r") as f: + print(f.read()) + +def do_list_pool_ca(cluster): + + h = build_pool_hash(cluster) + + print(f"Current CA pool hash: {h}") + + for filename in sorted(os.listdir(cluster.ca_pool_dir)): + if filename.endswith(".crt") and filename != "ca.crt": + print(get_cert_display_name(os.path.join(cluster.ca_pool_dir, filename))) diff --git a/deploy/operations/certificates-management/cluster.py b/deploy/operations/certificates-management/cluster.py new file mode 100644 index 000000000..00e918aa9 --- /dev/null +++ b/deploy/operations/certificates-management/cluster.py @@ -0,0 +1,135 @@ +import os + +from utils import slugify + + +class Cluster(object): + """Represent an instance of a cluster, expose paths""" + + def __init__(self, name, cluster_context, namespace, organization, nodes_count): + self._name = name + self.cluster_context = cluster_context + self.namespace = namespace + self.organization = organization + self.nodes_count = nodes_count + + @property + def name(self): + return slugify(self._name) + + @property + def directory(self): + # Replace characters breaking folder names + def remove_special_chars(s: str): + for c in [":", "/"]: + s = s.replace(c, "_") + return s + + return os.path.join(os.getcwd(), "workspace", remove_special_chars(self._name)) + + @property + def ca_key_dir(self): + return os.path.join(self.directory, "ca") + + @property + def ca_key_file(self): + return os.path.join(self.ca_key_dir, "ca.key") + + @property + def ca_cert_file(self): + return os.path.join(self.ca_key_dir, "ca.crt") + + @property + def ca_conf(self): + return os.path.join(self.ca_key_dir, "ca.conf") + + @property + def client_certs_dir(self): + return os.path.join(self.directory, "clients") + + @property + def client_ca(self): + return os.path.join(self.client_certs_dir, "root.crt") + + @property + def master_certs_dir(self): + return os.path.join(self.directory, "masters") + + @property + def master_ca(self): + return os.path.join(self.master_certs_dir, "ca.crt") + + @property + def tserver_certs_dir(self): + return os.path.join(self.directory, "tservers") + + @property + def tserver_ca(self): + return os.path.join(self.tserver_certs_dir, "ca.crt") + + @property + def ca_pool_dir(self): + return os.path.join(self.directory, "ca_pool") + + @property + def ca_pool_ca(self): + return os.path.join(self.ca_pool_dir, "ca.crt") + + @property + def is_ready(self): + return os.path.exists(self.ca_key_file) + + @property + def clients(self): + return ["yugabytedb"] # TODO: Do we need more, like a specifc one for the DSS? + + def get_client_cert_file(self, client): + return f"{self.client_certs_dir}/{client}.crt" + + def get_client_key_file(self, client): + return f"{self.client_certs_dir}/{client}.key" + + def get_client_csr_file(self, client): + return f"{self.ca_key_dir}/client.{client}.csr" + + def get_client_conf_file(self, client): + return f"{self.ca_key_dir}/client.{client}.conf" + + def is_client_ready(self, client): + return os.path.exists(self.get_client_cert_file(client)) + + def get_node_short_name(self, node_type, node_id): + return f"yb-{node_type}-{node_id}" + + def get_node_short_name_group(self, node_type, node_id): + short_name = self.get_node_short_name(node_type, node_id) + return f"{short_name}.yb-{node_type}s" + + def get_node_full_name(self, node_type, node_id): + short_name_group = self.get_node_short_name_group(node_type, node_id) + return f"{short_name_group}.{self.namespace}.svc.cluster.local" + + def get_node_full_name_without_group(self, node_type, node_id): + short_name = self.get_node_short_name(node_type, node_id) + return f"{short_name}.{self.namespace}.svc.cluster.local" + + def get_node_cert_file(self, node_type, node_id): + folder = getattr(self, f"{node_type}_certs_dir") + full_name = self.get_node_full_name(node_type, node_id) + return f"{folder}/node.{full_name}.crt" + + def get_node_key_file(self, node_type, node_id): + folder = getattr(self, f"{node_type}_certs_dir") + full_name = self.get_node_full_name(node_type, node_id) + return f"{folder}/node.{full_name}.key" + + def get_node_csr_file(self, node_type, node_id): + full_name = self.get_node_full_name(node_type, node_id) + return f"{self.ca_key_dir}/node.{full_name}.csr" + + def get_node_conf_file(self, node_type, node_id): + full_name = self.get_node_full_name(node_type, node_id) + return f"{self.ca_key_dir}/node.{full_name}.conf" + + def is_node_ready(self, node_type, node_id): + return os.path.exists(self.get_node_cert_file(node_type, node_id)) diff --git a/deploy/operations/certificates-management/dss-certs.py b/deploy/operations/certificates-management/dss-certs.py new file mode 100755 index 000000000..ec12c868f --- /dev/null +++ b/deploy/operations/certificates-management/dss-certs.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 + +import argparse +import logging +import shutil +import sys + +from apply import do_apply +from cluster import Cluster +from init import do_init +from nodes import do_generate_nodes +from ca_pool import do_get_pool_ca, do_get_ca, do_add_cas, do_list_pool_ca, do_remove_cas + +l = logging.getLogger(__name__) + + +def parse_args(): + parser = argparse.ArgumentParser( + description="Manage certificates for a DSS Instance using yugabyte" + ) + parser.add_argument( + "--name", + metavar="NAME", + required=True, + help="name of your cluster, should be unique to identify it", + ) + parser.add_argument( + "--organization", + metavar="ORGANIZATION", + default="generic-dss-organization", + help="name of your origanization", + ) + parser.add_argument( + "--cluster-context", + metavar="CLUSTER_CONTEXT", + required=True, + help="kubernetes cluster context name", + ) + parser.add_argument( + "--namespace", + metavar="NAMESPACE", + required=True, + help="kubernetes cluster namespace you are deploying to.", + ) + parser.add_argument( + "--nodes-count", + metavar="NODES_COUNT", + default="3", + help="Number of yugabyte nodes in the cluster, default to 3", + ) + parser.add_argument( + "--ca-file", + metavar="CA_FILE", + default="-", + help="CA file, for add/remove operation. Set to '-' to use stdin", + ) + parser.add_argument( + "--ca-serial", + metavar="CA_SERIAL", + help="CA serial, for remove operation. If set, --ca-file is ignored", + ) + parser.add_argument( + "action", + type=str, + help="action to be run", + choices=[ + "init", + "apply", + "regenerate-nodes", + "add-pool-ca", + "remove-pool-ca", + "list-pool-ca", + "get-pool-ca", + "get-ca", + "destroy", + ], + ) + parser.add_argument( + "--log-level", + type=str, + help="logging level", + default="INFO", + choices=[ + "DEBUG", + "INFO", + "WARNING", + "ERROR", + ], + ) + return parser.parse_args() + + +def main(): + + args = parse_args() + logging.basicConfig( + level=args.log_level, + format="%(asctime)-15s %(funcName)-25s %(levelname)-8s %(message)s", + ) + cluster = Cluster( + args.name, + args.cluster_context, + args.namespace, + args.organization, + args.nodes_count, + ) + + def read_input(): + if args.ca_file == "-": + return sys.stdin.read() + + with open(args.ca_file, 'r') as f: + return f.read() + + if args.action == "init": + do_init(cluster) + elif args.action == "regenerate-nodes": + do_generate_nodes(cluster) + elif args.action == "apply": + do_apply(cluster) + elif args.action == "add-pool-ca": + do_add_cas(cluster, read_input()) + elif args.action == "remove-pool-ca": + if args.ca_serial: + do_remove_cas(cluster, args.ca_serial) + else: + do_remove_cas(cluster, read_input()) + elif args.action == "list-pool-ca": + do_list_pool_ca(cluster) + elif args.action == "get-pool-ca": + do_get_pool_ca(cluster) + elif args.action == "get-ca": + do_get_ca(cluster) + elif args.action == "destroy": + if input("Are you sure? You will loose all your certificates! [yN]") == "y": + shutil.rmtree(cluster.directory) + l.warning(f"Destroyed cluster certificates") + else: + l.info(f"Cancelled removal") + + +if __name__ == "__main__": + main() diff --git a/deploy/operations/certificates-management/init.py b/deploy/operations/certificates-management/init.py new file mode 100644 index 000000000..f5f1b8bcb --- /dev/null +++ b/deploy/operations/certificates-management/init.py @@ -0,0 +1,240 @@ +import logging +import os +import subprocess +import sys + +from ca_pool import do_add_cas +from nodes import do_generate_nodes +from utils import get_cert_display_name + +l = logging.getLogger(__name__) + + +def generate_ca_config(cluster): + l.debug("Creating CA configuration files") + + with open(cluster.ca_conf, "w") as f: + f.write( + f""" + [ ca ] + default_ca = my_ca + +[ my_ca ] +default_days = 3650 + +serial = {cluster.ca_key_dir}/serial.txt +database = {cluster.ca_key_dir}/index.txt +default_md = sha256 +policy = my_policy + +[ my_policy ] + +organizationName = supplied +commonName = supplied + +[req] +prompt=no +distinguished_name = my_distinguished_name +x509_extensions = my_extensions + +[ my_distinguished_name ] +organizationName = {cluster.organization} +commonName = CA.{cluster.name} + +[ my_extensions ] +keyUsage = critical,digitalSignature,nonRepudiation,keyEncipherment,keyCertSign +basicConstraints = critical,CA:true,pathlen:1 + +""" + ) + + with open(f"{cluster.ca_key_dir}/serial.txt", "w") as f: + f.write("0001") + + with open(f"{cluster.ca_key_dir}/index.txt", "w") as f: + f.write("") + + l.info("Created CA configuration files") + + +def generate_ca_key(cluster): + l.debug("Generating CA private key") + subprocess.check_call( + ["openssl", "genrsa", "-out", cluster.ca_key_file, "4096"], + stdout=subprocess.DEVNULL, + ) + l.info("Generated CA private key") + + +def generate_ca_cert(cluster): + l.debug("Generating CA certificate") + subprocess.check_call( + [ + "openssl", + "req", + "-new", + "-x509", + "-days", + "3650", + "-config", + cluster.ca_conf, + "-key", + cluster.ca_key_file, + "-out", + cluster.ca_cert_file, + ], + stdout=subprocess.DEVNULL, + ) + + name = get_cert_display_name(cluster.ca_cert_file) + + l.info(f"Generated CA certificate '{name}'") + + +def generate_ca(cluster): + generate_ca_config(cluster) + generate_ca_key(cluster) + generate_ca_cert(cluster) + + +def make_directories(cluster): + + l.debug("Creating directories") + + if not os.path.exists("workspace"): + os.makedirs("workspace") + + os.mkdir(cluster.directory) + os.mkdir(cluster.ca_key_dir) + os.mkdir(cluster.master_certs_dir) + os.mkdir(cluster.tserver_certs_dir) + os.mkdir(cluster.client_certs_dir) + os.mkdir(cluster.ca_pool_dir) + + l.info("Created directories") + + +def generate_clients(cluster): + + for client in cluster.clients: + if cluster.is_client_ready(client): + l.debug(f"Client '{client}' certificates already generated") + continue + generate_client_config(cluster, client) + generate_client_key(cluster, client) + generate_client_csr(cluster, client) + generate_client_cert(cluster, client) + + +def generate_client_config(cluster, client): + + l.debug(f"Creating client '{client}' configuration file") + + with open(cluster.get_client_conf_file(client), "w") as f: + f.write( + f"""[ req ] +prompt=no +distinguished_name = my_distinguished_name + +[ my_distinguished_name ] +organizationName = {cluster.organization} +commonName = client.{client} +""" + ) + + l.info(f"Created client '{client}' configuration file") + + +def generate_client_key(cluster, client): + + l.debug(f"Generating client '{client}' private key") + + subprocess.check_call( + ["openssl", "genrsa", "-out", cluster.get_client_key_file(client), "4096"] + ) + + l.info(f"Generated client '{client}' private key") + + +def generate_client_csr(cluster, client): + + l.debug(f"Generating client '{client}' certificate request") + + subprocess.check_call( + [ + "openssl", + "req", + "-new", + "-config", + cluster.get_client_conf_file(client), + "-key", + cluster.get_client_key_file(client), + "-out", + cluster.get_client_csr_file(client), + ], + stdout=subprocess.DEVNULL, + ) + + l.info(f"Generated client '{client}' certificate request") + + +def generate_client_cert(cluster, client): + + l.debug(f"Generating client '{client}' certificate") + + subprocess.check_call( + [ + "openssl", + "ca", + "-config", + cluster.ca_conf, + "-keyfile", + cluster.ca_key_file, + "-cert", + cluster.ca_cert_file, + "-policy", + "my_policy", + "-out", + cluster.get_client_cert_file(client), + "-outdir", + cluster.client_certs_dir, + "-in", + cluster.get_client_csr_file(client), + "-days", + "3650", + "-batch", + "-extfile", + cluster.get_client_conf_file(client), + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + name = get_cert_display_name(cluster.get_client_cert_file(client)) + + l.info(f"Generated client '{client}' certificate '{name}'") + + +def do_init(cluster): + """Initialize a new cluster""" + + l.info("Initialization of a new cluster") + + if cluster.is_ready: + l.error("Cluster is already initialized, unable to continue") + sys.exit(1) + else: + l.debug("Cluster is not already initialized, continuing") + + make_directories(cluster) + generate_ca(cluster) + generate_clients(cluster) + + do_generate_nodes(cluster) + + with open(cluster.ca_cert_file, "r") as f: + do_add_cas(cluster, f.read()) + + l.info( + "The new cluster certificates are ready! Don't forget to 'apply' the configuration." + ) diff --git a/deploy/operations/certificates-management/nodes.py b/deploy/operations/certificates-management/nodes.py new file mode 100644 index 000000000..0a2e06c23 --- /dev/null +++ b/deploy/operations/certificates-management/nodes.py @@ -0,0 +1,152 @@ +import logging +import subprocess +import sys + +from utils import get_cert_display_name + + +def generate_node_config(cluster, node_type, node_id): + + l.debug(f"Creating {node_type} #{node_id} configuration file") + + short_name = cluster.get_node_short_name(node_type, node_id) + short_name_group = cluster.get_node_short_name_group(node_type, node_id) + full_name = cluster.get_node_full_name(node_type, node_id) + full_name_without_group = cluster.get_node_full_name_without_group( + node_type, node_id + ) + + with open(cluster.get_node_conf_file(node_type, node_id), "w") as f: + f.write( + f"""[ req ] +prompt=no +distinguished_name = my_distinguished_name + +[ my_distinguished_name ] +organizationName = {cluster.organization} +commonName = {full_name} + +# Multiple subject alternative names (SANs) such as IP Address, +# DNS Name, Email, URI, and so on, can be specified under this section +[ req_ext] +subjectAltName = @alt_names +[alt_names] +DNS.1 = {short_name} +DNS.2 = {full_name} +DNS.3 = {short_name_group} +DNS.4 = {full_name_without_group} +DNS.5 = yb-{node_type}s +DNS.6 = yb-{node_type}s.{cluster.namespace} +DNS.7 = yb-{node_type}s.{cluster.namespace}.svc.cluster.local +""" + ) + + l.info(f"Created {node_type} #{node_id} configuration file") + + +def generate_node_key(cluster, node_type, node_id): + + l.debug(f"Generating {node_type} #{node_id} private key") + + subprocess.check_call( + [ + "openssl", + "genrsa", + "-out", + cluster.get_node_key_file(node_type, node_id), + "4096", + ] + ) + + l.info(f"Generated {node_type} #{node_id} private key") + + +def generate_node_csr(cluster, node_type, node_id): + + l.debug(f"Generating {node_type} #{node_id} certificate request") + + subprocess.check_call( + [ + "openssl", + "req", + "-new", + "-config", + cluster.get_node_conf_file(node_type, node_id), + "-key", + cluster.get_node_key_file(node_type, node_id), + "-out", + cluster.get_node_csr_file(node_type, node_id), + ], + stdout=subprocess.DEVNULL, + ) + + l.info(f"Generated {node_type} #{node_id} certificate request") + + +def generate_node_cert(cluster, node_type, node_id): + + l.debug(f"Generating {node_type} #{node_id} certificate") + + subprocess.check_call( + [ + "openssl", + "ca", + "-config", + cluster.ca_conf, + "-keyfile", + cluster.ca_key_file, + "-cert", + cluster.ca_cert_file, + "-policy", + "my_policy", + "-out", + cluster.get_node_cert_file(node_type, node_id), + "-outdir", + getattr(cluster, f"{node_type}_certs_dir"), + "-in", + cluster.get_node_csr_file(node_type, node_id), + "-days", + "3650", + "-batch", + "-extfile", + cluster.get_node_conf_file(node_type, node_id), + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + name = get_cert_display_name(cluster.get_node_cert_file(node_type, node_id)) + + l.info(f"Generated {node_type} #{node_id} certificate '{name}'") + + +def generate_node(cluster, node_type, node_id): + if cluster.is_node_ready(node_type, node_id): + l.debug(f"{node_type} #{node_id} certificates already generated") + return + + generate_node_config(cluster, node_type, node_id) + generate_node_key(cluster, node_type, node_id) + generate_node_csr(cluster, node_type, node_id) + generate_node_cert(cluster, node_type, node_id) + + +l = logging.getLogger(__name__) + + +def do_generate_nodes(cluster): + """Generate certificates for all nodes (master and tserver)""" + + l.info("Generation of nodes certificates") + + if not cluster.is_ready: + l.error("Cluster is not already initialized, unable to continue") + sys.exit(1) + else: + l.debug("Cluster is initialized, continuing") + + for node_type in ["master", "tserver"]: + for node_id in range(0, int(cluster.nodes_count)): + generate_node(cluster, node_type, node_id) + + l.info("All nodes certificates are ready") diff --git a/deploy/operations/certificates-management/utils.py b/deploy/operations/certificates-management/utils.py new file mode 100644 index 000000000..4947a3d3f --- /dev/null +++ b/deploy/operations/certificates-management/utils.py @@ -0,0 +1,51 @@ +import logging +import re +import ssl +import sys +import unicodedata + +l = logging.getLogger(__name__) + + +def slugify(text): + text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode("ascii") + text = text.lower() + text = re.sub(r"[^a-z0-9_\.]+", "-", text) + text = text.strip("-") + return text + + +def get_cert_display_name(path): + try: + cert_dict = ssl._ssl._test_decode_cert( + path + ) # We do use an internal function, to avoid installing dependencies + except Exception as e: + l.error(e) + sys.exit(1) + + serial = cert_dict.get("serialNumber", "") + + orga = "" + cn = "" + + for kv in cert_dict.get("subject", []): + for k, v in kv: + if k == "organizationName": + orga = v + elif k == "commonName": + cn = v + + return f"SN={serial[-8:]}, O={orga}, CN={cn}" + + +def get_cert_serial(path): + try: + cert_dict = ssl._ssl._test_decode_cert( + path + ) # We do use an internal function, to avoid installing dependencies + except Exception as e: + l.error(e) + sys.exit(1) + + return cert_dict["serialNumber"]