From 30e0dc64b5bd44bd9003426239dcbdd3202dc234 Mon Sep 17 00:00:00 2001 From: Mia Altieri Date: Tue, 8 Oct 2024 07:55:37 +0000 Subject: [PATCH 1/3] update libs --- .../mongodb/v0/config_server_interface.py | 37 ++++++++++++++----- lib/charms/mongodb/v0/mongo.py | 10 ++++- lib/charms/mongodb/v1/helpers.py | 32 +++++++++++++--- lib/charms/mongos/v0/set_status.py | 1 + 4 files changed, 64 insertions(+), 16 deletions(-) diff --git a/lib/charms/mongodb/v0/config_server_interface.py b/lib/charms/mongodb/v0/config_server_interface.py index cdb733d9..44f5b26b 100644 --- a/lib/charms/mongodb/v0/config_server_interface.py +++ b/lib/charms/mongodb/v0/config_server_interface.py @@ -11,10 +11,11 @@ from charms.data_platform_libs.v0.data_interfaces import ( DatabaseProvides, + DatabaseRequestedEvent, DatabaseRequires, ) from charms.mongodb.v1.mongos import MongosConnection -from ops.charm import CharmBase, EventBase, RelationBrokenEvent +from ops.charm import CharmBase, EventBase, RelationBrokenEvent, RelationChangedEvent from ops.framework import Object from ops.model import ( ActiveStatus, @@ -42,7 +43,7 @@ # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 11 +LIBPATCH = 13 class ClusterProvider(Object): @@ -57,6 +58,9 @@ def __init__( self.database_provides = DatabaseProvides(self.charm, relation_name=self.relation_name) super().__init__(charm, self.relation_name) + self.framework.observe( + self.database_provides.on.database_requested, self._on_database_requested + ) self.framework.observe( charm.on[self.relation_name].relation_changed, self._on_relation_changed ) @@ -105,8 +109,14 @@ def is_valid_mongos_integration(self) -> bool: return True - def _on_relation_changed(self, event) -> None: - """Handles providing mongos with KeyFile and hosts.""" + def _on_database_requested(self, event: DatabaseRequestedEvent | RelationChangedEvent) -> None: + """Handles the database requested event. + + The first time secrets are written to relations should be on this event. + + Note: If secrets are written for the first time on other events we risk + the chance of writing secrets in plain sight. + """ if not self.pass_hook_checks(event): if not self.is_valid_mongos_integration(): self.charm.status.set_and_share_status( @@ -116,12 +126,9 @@ def _on_relation_changed(self, event) -> None: ) logger.info("Skipping relation joined event: hook checks did not pass") return - config_server_db = self.generate_config_server_db() - # create user and set secrets for mongos relation self.charm.client_relations.oversee_users(None, None) - relation_data = { KEYFILE_KEY: self.charm.get_secret( Config.Relations.APP_SCOPE, Config.Secrets.SECRET_KEYFILE_NAME @@ -135,9 +142,20 @@ def _on_relation_changed(self, event) -> None: ) if int_tls_ca: relation_data[INT_TLS_CA_KEY] = int_tls_ca - self.database_provides.update_relation_data(event.relation.id, relation_data) + def _on_relation_changed(self, event: RelationChangedEvent) -> None: + """Handles providing mongos with KeyFile and hosts.""" + # First we need to ensure that the database requested event has run + # otherwise we risk the chance of writing secrets in plain sight. + if not self.database_provides.fetch_relation_field(event.relation.id, "database"): + logger.info("Database Requested has not run yet, skipping.") + event.defer() + return + + # TODO : This workflow is a fix until we have time for a better and complete fix (DPE-5513) + self._on_database_requested(event) + def _on_relation_broken(self, event) -> None: if self.charm.upgrade_in_progress: logger.warning( @@ -300,7 +318,8 @@ def _on_relation_changed(self, event) -> None: return self.charm.status.set_and_share_status(ActiveStatus()) - self.charm.mongos_intialised = True + if self.charm.unit.is_leader(): + self.charm.mongos_initialised = True def _on_relation_broken(self, event: RelationBrokenEvent) -> None: # Only relation_deparated events can check if scaling down diff --git a/lib/charms/mongodb/v0/mongo.py b/lib/charms/mongodb/v0/mongo.py index f8ef0e44..9ae3cd2d 100644 --- a/lib/charms/mongodb/v0/mongo.py +++ b/lib/charms/mongodb/v0/mongo.py @@ -31,7 +31,7 @@ class NotReadyError(PyMongoError): # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 1 +LIBPATCH = 2 ADMIN_AUTH_SOURCE = "authSource=admin" SYSTEM_DBS = ("admin", "local", "config") @@ -40,6 +40,7 @@ class NotReadyError(PyMongoError): {"role": "userAdminAnyDatabase", "db": "admin"}, {"role": "readWriteAnyDatabase", "db": "admin"}, {"role": "userAdmin", "db": "admin"}, + {"role": "enableSharding", "db": "admin"}, ], "monitor": [ {"role": "explainRole", "db": "admin"}, @@ -127,7 +128,12 @@ def uri(self): def supported_roles(config: MongoConfiguration): """Return the supported roles for the given configuration.""" - return REGULAR_ROLES | {"default": [{"db": config.database, "role": "readWrite"}]} + return REGULAR_ROLES | { + "default": [ + {"db": config.database, "role": "readWrite"}, + {"db": config.database, "role": "enableSharding"}, + ] + } class MongoConnection: diff --git a/lib/charms/mongodb/v1/helpers.py b/lib/charms/mongodb/v1/helpers.py index 937786b8..1b2f1064 100644 --- a/lib/charms/mongodb/v1/helpers.py +++ b/lib/charms/mongodb/v1/helpers.py @@ -8,7 +8,7 @@ import secrets import string import subprocess -from typing import List +from typing import List, Mapping from charms.mongodb.v1.mongodb import MongoConfiguration from ops.model import ActiveStatus, MaintenanceStatus, StatusBase, WaitingStatus @@ -23,7 +23,7 @@ # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 8 +LIBPATCH = 12 # path to store mongodb ketFile KEY_FILE = "keyFile" @@ -89,7 +89,7 @@ def get_create_user_cmd(config: MongoConfiguration, mongo_path=MONGO_SHELL) -> L "mongodb://localhost/admin", "--quiet", "--eval", - "db.createUser({" + '"db.createUser({' f" user: '{config.username}'," " pwd: passwordPrompt()," " roles:[" @@ -99,7 +99,7 @@ def get_create_user_cmd(config: MongoConfiguration, mongo_path=MONGO_SHELL) -> L " ]," " mechanisms: ['SCRAM-SHA-256']," " passwordDigestor: 'server'," - "})", + '})"', ] @@ -118,7 +118,7 @@ def get_mongos_args( binding_ips = ( "--bind_ip_all" if external_connectivity - else f"--bind_ip {MONGODB_COMMON_DIR}/var/mongodb-27018.sock" + else f"--bind_ip {MONGODB_COMMON_DIR}/var/mongodb-27018.sock --filePermissions 0766" ) # mongos running on the config server communicates through localhost @@ -320,3 +320,25 @@ def add_args_to_env(var: str, args: str): with open(Config.ENV_VAR_PATH, "w") as service_file: service_file.writelines(env_vars) + + +def safe_exec( + command: list[str] | str, + env: Mapping[str, str] | None = None, + working_dir: str | None = None, +) -> str: + """Execs a command on the workload in a safe way.""" + try: + output = subprocess.check_output( + command, + stderr=subprocess.PIPE, + universal_newlines=True, + shell=isinstance(command, str), + env=env, + cwd=working_dir, + ) + logger.debug(f"{output=}") + return output + except subprocess.CalledProcessError as err: + logger.error(f"cmd failed - {err.cmd}, {err.stdout}, {err.stderr}") + raise diff --git a/lib/charms/mongos/v0/set_status.py b/lib/charms/mongos/v0/set_status.py index e70bedf0..3f57015e 100644 --- a/lib/charms/mongos/v0/set_status.py +++ b/lib/charms/mongos/v0/set_status.py @@ -61,6 +61,7 @@ def clear_status(self, status_to_clear): status_to_clear, self.charm.unit.status, ) + return # TODO: In the future compute the next highest priority status. self.charm.unit.status = ActiveStatus() From 0fe2dd558df636320f6e3f4af198f46a456b61b0 Mon Sep 17 00:00:00 2001 From: Mia Altieri Date: Tue, 8 Oct 2024 09:28:11 +0000 Subject: [PATCH 2/3] lib no longer used --- lib/charms/mongodb/v0/mongodb_tls.py | 365 --------------------------- 1 file changed, 365 deletions(-) delete mode 100644 lib/charms/mongodb/v0/mongodb_tls.py diff --git a/lib/charms/mongodb/v0/mongodb_tls.py b/lib/charms/mongodb/v0/mongodb_tls.py deleted file mode 100644 index 6669c1c3..00000000 --- a/lib/charms/mongodb/v0/mongodb_tls.py +++ /dev/null @@ -1,365 +0,0 @@ -# Copyright 2023 Canonical Ltd. -# See LICENSE file for licensing details. - -"""In this class we manage client database relations. - -This class creates user and database for each application relation -and expose needed information for client connection via fields in -external relation. -""" -import base64 -import logging -import re -import socket -from typing import List, Optional, Tuple - -from charms.tls_certificates_interface.v3.tls_certificates import ( - CertificateAvailableEvent, - CertificateExpiringEvent, - TLSCertificatesRequiresV3, - generate_csr, - generate_private_key, -) -from ops.charm import ActionEvent, RelationBrokenEvent, RelationJoinedEvent -from ops.framework import Object -from ops.model import ActiveStatus, MaintenanceStatus, Unit, WaitingStatus - -from config import Config - -UNIT_SCOPE = Config.Relations.UNIT_SCOPE -Scopes = Config.Relations.Scopes - - -# The unique Charmhub library identifier, never change it -LIBID = "e02a50f0795e4dd292f58e93b4f493dd" - -# Increment this major API version when introducing breaking changes -LIBAPI = 0 - -# Increment this PATCH version before using `charmcraft publish-lib` or reset -# to 0 if you are raising the major API version -LIBPATCH = 15 - -logger = logging.getLogger(__name__) - - -class MongoDBTLS(Object): - """In this class we manage client database relations.""" - - def __init__(self, charm, peer_relation, substrate): - """Manager of MongoDB client relations.""" - super().__init__(charm, "client-relations") - self.charm = charm - self.substrate = substrate - self.peer_relation = peer_relation - self.certs = TLSCertificatesRequiresV3(self.charm, Config.TLS.TLS_PEER_RELATION) - self.framework.observe( - self.charm.on.set_tls_private_key_action, self._on_set_tls_private_key - ) - self.framework.observe( - self.charm.on[Config.TLS.TLS_PEER_RELATION].relation_joined, - self._on_tls_relation_joined, - ) - self.framework.observe( - self.charm.on[Config.TLS.TLS_PEER_RELATION].relation_broken, - self._on_tls_relation_broken, - ) - self.framework.observe(self.certs.on.certificate_available, self._on_certificate_available) - self.framework.observe(self.certs.on.certificate_expiring, self._on_certificate_expiring) - - def is_tls_enabled(self, internal: bool): - """Returns a boolean indicating if TLS for a given internal/external is enabled.""" - return self.get_tls_secret(internal, Config.TLS.SECRET_CERT_LABEL) is not None - - def _on_set_tls_private_key(self, event: ActionEvent) -> None: - """Set the TLS private key, which will be used for requesting the certificate.""" - logger.debug("Request to set TLS private key received.") - if self.charm.is_role(Config.Role.MONGOS) and not self.charm.has_config_server(): - logger.error( - "mongos is not running (not integrated to config-server) deferring renewal of certificates." - ) - event.fail("Mongos cannot set TLS keys until integrated to config-server.") - return - - if self.charm.upgrade_in_progress: - logger.warning("Setting TLS key during an upgrade is not supported.") - event.fail("Setting TLS key during an upgrade is not supported.") - return - - try: - self.request_certificate(event.params.get("external-key", None), internal=False) - self.request_certificate(event.params.get("internal-key", None), internal=True) - logger.debug("Successfully set TLS private key.") - except ValueError as e: - event.fail(str(e)) - - def request_certificate( - self, - param: Optional[str], - internal: bool, - ): - """Request TLS certificate.""" - if param is None: - key = generate_private_key() - else: - key = self._parse_tls_file(param) - - csr = generate_csr( - private_key=key, - subject=self._get_subject_name(), - organization=self._get_subject_name(), - sans=self._get_sans(), - sans_ip=[str(self.charm.model.get_binding(self.peer_relation).network.bind_address)], - ) - self.set_tls_secret(internal, Config.TLS.SECRET_KEY_LABEL, key.decode("utf-8")) - self.set_tls_secret(internal, Config.TLS.SECRET_CSR_LABEL, csr.decode("utf-8")) - self.set_tls_secret(internal, Config.TLS.SECRET_CERT_LABEL, None) - - label = "int" if internal else "ext" - self.charm.unit_peer_data[f"{label}_certs_subject"] = self._get_subject_name() - self.charm.unit_peer_data[f"{label}_certs_subject"] = self._get_subject_name() - - if self.charm.model.get_relation(Config.TLS.TLS_PEER_RELATION): - self.certs.request_certificate_creation(certificate_signing_request=csr) - - @staticmethod - def _parse_tls_file(raw_content: str) -> bytes: - """Parse TLS files from both plain text or base64 format.""" - if re.match(r"(-+(BEGIN|END) [A-Z ]+-+)", raw_content): - return ( - re.sub( - r"(-+(BEGIN|END) [A-Z ]+-+)", - "\\1", - raw_content, - ) - .rstrip() - .encode("utf-8") - ) - return base64.b64decode(raw_content) - - def _on_tls_relation_joined(self, event: RelationJoinedEvent) -> None: - """Request certificate when TLS relation joined.""" - if self.charm.is_role(Config.Role.MONGOS) and not self.charm.has_config_server(): - logger.info( - "mongos is not running (not integrated to config-server) deferring renewal of certificates." - ) - event.defer() - return - - if self.charm.upgrade_in_progress: - logger.warning( - "Enabling TLS is not supported during an upgrade. The charm may be in a broken, unrecoverable state." - ) - event.defer() - return - - self.request_certificate(None, internal=True) - self.request_certificate(None, internal=False) - - def _on_tls_relation_broken(self, event: RelationBrokenEvent) -> None: - """Disable TLS when TLS relation broken.""" - logger.debug("Disabling external and internal TLS for unit: %s", self.charm.unit.name) - if self.charm.upgrade_in_progress: - logger.warning( - "Disabling TLS is not supported during an upgrade. The charm may be in a broken, unrecoverable state." - ) - - for internal in [True, False]: - self.set_tls_secret(internal, Config.TLS.SECRET_CA_LABEL, None) - self.set_tls_secret(internal, Config.TLS.SECRET_CERT_LABEL, None) - self.set_tls_secret(internal, Config.TLS.SECRET_CHAIN_LABEL, None) - - if self.charm.is_role(Config.Role.CONFIG_SERVER): - self.charm.cluster.update_ca_secret(new_ca=None) - self.charm.config_server.update_ca_secret(new_ca=None) - - logger.info("Restarting mongod with TLS disabled.") - self.charm.status.set_and_share_status(MaintenanceStatus("disabling TLS")) - self.charm.delete_tls_certificate_from_workload() - self.charm.restart_charm_services() - self.charm.status.set_and_share_status(ActiveStatus()) - - def _on_certificate_available(self, event: CertificateAvailableEvent) -> None: - """Enable TLS when TLS certificate available.""" - if self.charm.is_role(Config.Role.MONGOS) and not self.charm.config_server_db: - logger.debug( - "mongos requires config-server in order to start, do not restart with TLS until integrated to config-server" - ) - event.defer() - return - - int_csr = self.get_tls_secret(internal=True, label_name=Config.TLS.SECRET_CSR_LABEL) - ext_csr = self.get_tls_secret(internal=False, label_name=Config.TLS.SECRET_CSR_LABEL) - - if ext_csr and event.certificate_signing_request.rstrip() == ext_csr.rstrip(): - logger.debug("The external TLS certificate available.") - internal = False - elif int_csr and event.certificate_signing_request.rstrip() == int_csr.rstrip(): - logger.debug("The internal TLS certificate available.") - internal = True - else: - logger.error("An unknown certificate is available -- ignoring.") - return - - self.set_tls_secret( - internal, - Config.TLS.SECRET_CHAIN_LABEL, - "\n".join(event.chain) if event.chain is not None else None, - ) - self.set_tls_secret(internal, Config.TLS.SECRET_CERT_LABEL, event.certificate) - self.set_tls_secret(internal, Config.TLS.SECRET_CA_LABEL, event.ca) - - if self.charm.is_role(Config.Role.CONFIG_SERVER) and internal: - self.charm.cluster.update_ca_secret(new_ca=event.ca) - self.charm.config_server.update_ca_secret(new_ca=event.ca) - - if self.waiting_for_certs(): - logger.debug( - "Defer till both internal and external TLS certificates available to avoid second restart." - ) - event.defer() - return - - logger.info("Restarting mongod with TLS enabled.") - - self.charm.delete_tls_certificate_from_workload() - self.charm.push_tls_certificate_to_workload() - self.charm.status.set_and_share_status(MaintenanceStatus("enabling TLS")) - self.charm.restart_charm_services() - - if not self.charm.is_db_service_ready(): - self.charm.status.set_and_share_status(WaitingStatus("Waiting for MongoDB to start")) - elif self.charm.unit.status == WaitingStatus( - "Waiting for MongoDB to start" - ) or self.charm.unit.status == MaintenanceStatus("enabling TLS"): - # clear waiting status if db service is ready - self.charm.status.set_and_share_status(ActiveStatus()) - - def waiting_for_certs(self): - """Returns a boolean indicating whether additional certs are needed.""" - if not self.get_tls_secret(internal=True, label_name=Config.TLS.SECRET_CERT_LABEL): - logger.debug("Waiting for internal certificate.") - return True - if not self.get_tls_secret(internal=False, label_name=Config.TLS.SECRET_CERT_LABEL): - logger.debug("Waiting for external certificate.") - return True - - return False - - def _on_certificate_expiring(self, event: CertificateExpiringEvent) -> None: - """Request the new certificate when old certificate is expiring.""" - if self.charm.is_role(Config.Role.MONGOS) and not self.charm.has_config_server(): - logger.info( - "mongos is not running (not integrated to config-server) deferring renewal of certificates." - ) - event.defer() - return - - if ( - event.certificate.rstrip() - == self.get_tls_secret( - internal=False, label_name=Config.TLS.SECRET_CERT_LABEL - ).rstrip() - ): - logger.debug("The external TLS certificate expiring.") - internal = False - elif ( - event.certificate.rstrip() - == self.get_tls_secret(internal=True, label_name=Config.TLS.SECRET_CERT_LABEL).rstrip() - ): - logger.debug("The internal TLS certificate expiring.") - - internal = True - else: - logger.error("An unknown certificate expiring.") - return - - logger.debug("Generating a new Certificate Signing Request.") - key = self.get_tls_secret(internal, Config.TLS.SECRET_KEY_LABEL).encode("utf-8") - old_csr = self.get_tls_secret(internal, Config.TLS.SECRET_CSR_LABEL).encode("utf-8") - new_csr = generate_csr( - private_key=key, - subject=self._get_subject_name(), - organization=self._get_subject_name(), - sans=self._get_sans(), - sans_ip=[str(self.charm.model.get_binding(self.peer_relation).network.bind_address)], - ) - logger.debug("Requesting a certificate renewal.") - - self.certs.request_certificate_renewal( - old_certificate_signing_request=old_csr, - new_certificate_signing_request=new_csr, - ) - - self.set_tls_secret(internal, Config.TLS.SECRET_CSR_LABEL, new_csr.decode("utf-8")) - - def _get_sans(self) -> List[str]: - """Create a list of DNS names for a MongoDB unit. - - Returns: - A list representing the hostnames of the MongoDB unit. - """ - unit_id = self.charm.unit.name.split("/")[1] - return [ - f"{self.charm.app.name}-{unit_id}", - socket.getfqdn(), - f"{self.charm.app.name}-{unit_id}.{self.charm.app.name}-endpoints", - str(self.charm.model.get_binding(self.peer_relation).network.bind_address), - "localhost", - ] - - def get_tls_files(self, internal: bool) -> Tuple[Optional[str], Optional[str]]: - """Prepare TLS files in special MongoDB way. - - MongoDB needs two files: - — CA file should have a full chain. - — PEM file should have private key and certificate without certificate chain. - """ - scope = "internal" if internal else "external" - if not self.is_tls_enabled(internal): - logging.debug(f"TLS disabled for {scope}") - return None, None - logging.debug(f"TLS *enabled* for {scope}, fetching data for CA and PEM files ") - - ca = self.get_tls_secret(internal, Config.TLS.SECRET_CA_LABEL) - chain = self.get_tls_secret(internal, Config.TLS.SECRET_CHAIN_LABEL) - ca_file = chain if chain else ca - - key = self.get_tls_secret(internal, Config.TLS.SECRET_KEY_LABEL) - cert = self.get_tls_secret(internal, Config.TLS.SECRET_CERT_LABEL) - pem_file = key - if cert: - pem_file = key + "\n" + cert if key else cert - - return ca_file, pem_file - - def get_host(self, unit: Unit): - """Retrieves the hostname of the unit based on the substrate.""" - if self.substrate == "vm": - return self.charm.unit_ip(unit) - else: - return self.charm.get_hostname_for_unit(unit) - - def set_tls_secret(self, internal: bool, label_name: str, contents: str) -> None: - """Sets TLS secret, based on whether or not it is related to internal connections.""" - scope = "int" if internal else "ext" - label_name = f"{scope}-{label_name}" - self.charm.set_secret(UNIT_SCOPE, label_name, contents) - - def get_tls_secret(self, internal: bool, label_name: str) -> str: - """Gets TLS secret, based on whether or not it is related to internal connections.""" - scope = "int" if internal else "ext" - label_name = f"{scope}-{label_name}" - return self.charm.get_secret(UNIT_SCOPE, label_name) - - def _get_subject_name(self) -> str: - """Generate the subject name for CSR.""" - # In sharded MongoDB deployments it is a requirement that all subject names match across - # all cluster components. The config-server name is the source of truth across mongos and - # shard deployments. - if not self.charm.is_role(Config.Role.CONFIG_SERVER): - # until integrated with config-server use current app name as - # subject name - return self.charm.get_config_server_name() or self.charm.app.name - - return self.charm.app.name From 280d0b6e94c3ad8a3a0cfa5dd352113dc88b5521 Mon Sep 17 00:00:00 2001 From: Mia Altieri Date: Tue, 8 Oct 2024 09:38:00 +0000 Subject: [PATCH 3/3] use new tls --- lib/charms/mongodb/v1/mongodb_tls.py | 430 +++++++++++++++++++++++++++ src/charm.py | 2 +- 2 files changed, 431 insertions(+), 1 deletion(-) create mode 100644 lib/charms/mongodb/v1/mongodb_tls.py diff --git a/lib/charms/mongodb/v1/mongodb_tls.py b/lib/charms/mongodb/v1/mongodb_tls.py new file mode 100644 index 00000000..8f00bde7 --- /dev/null +++ b/lib/charms/mongodb/v1/mongodb_tls.py @@ -0,0 +1,430 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +"""In this class we manage client database relations. + +This class creates user and database for each application relation +and expose needed information for client connection via fields in +external relation. +""" +import base64 +import json +import logging +import re +import socket +from typing import Dict, List, Optional, Tuple + +from charms.tls_certificates_interface.v3.tls_certificates import ( + CertificateAvailableEvent, + CertificateExpiringEvent, + TLSCertificatesRequiresV3, + generate_csr, + generate_private_key, +) +from cryptography import x509 +from cryptography.hazmat.backends import default_backend +from ops.charm import ActionEvent, RelationBrokenEvent, RelationJoinedEvent +from ops.framework import Object +from ops.model import ActiveStatus, MaintenanceStatus, WaitingStatus + +from config import Config + +UNIT_SCOPE = Config.Relations.UNIT_SCOPE +Scopes = Config.Relations.Scopes +SANS_DNS_KEY = "sans_dns" +SANS_IPS_KEY = "sans_ips" + +# The unique Charmhub library identifier, never change it +LIBID = "e02a50f0795e4dd292f58e93b4f493dd" + +# Increment this major API version when introducing breaking changes +LIBAPI = 1 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 4 + +WAIT_CERT_UPDATE = "wait-cert-updated" + +logger = logging.getLogger(__name__) + + +class MongoDBTLS(Object): + """In this class we manage client database relations.""" + + def __init__(self, charm, peer_relation, substrate): + """Manager of MongoDB client relations.""" + super().__init__(charm, "client-relations") + self.charm = charm + self.substrate = substrate + self.peer_relation = peer_relation + self.certs = TLSCertificatesRequiresV3(self.charm, Config.TLS.TLS_PEER_RELATION) + self.framework.observe( + self.charm.on.set_tls_private_key_action, self._on_set_tls_private_key + ) + self.framework.observe( + self.charm.on[Config.TLS.TLS_PEER_RELATION].relation_joined, + self._on_tls_relation_joined, + ) + self.framework.observe( + self.charm.on[Config.TLS.TLS_PEER_RELATION].relation_broken, + self._on_tls_relation_broken, + ) + self.framework.observe(self.certs.on.certificate_available, self._on_certificate_available) + self.framework.observe(self.certs.on.certificate_expiring, self._on_certificate_expiring) + + def is_tls_enabled(self, internal: bool): + """Returns a boolean indicating if TLS for a given internal/external is enabled.""" + return self.get_tls_secret(internal, Config.TLS.SECRET_CERT_LABEL) is not None + + def _on_set_tls_private_key(self, event: ActionEvent) -> None: + """Set the TLS private key, which will be used for requesting the certificate.""" + logger.debug("Request to set TLS private key received.") + if self.charm.is_role(Config.Role.MONGOS) and not self.charm.has_config_server(): + logger.error( + "mongos is not running (not integrated to config-server) deferring renewal of certificates." + ) + event.fail("Mongos cannot set TLS keys until integrated to config-server.") + return + + if self.charm.upgrade_in_progress: + logger.warning("Setting TLS key during an upgrade is not supported.") + event.fail("Setting TLS key during an upgrade is not supported.") + return + + try: + self.request_certificate(event.params.get("external-key", None), internal=False) + self.request_certificate(event.params.get("internal-key", None), internal=True) + logger.debug("Successfully set TLS private key.") + except ValueError as e: + event.fail(str(e)) + + def request_certificate( + self, + param: Optional[str], + internal: bool, + ): + """Request TLS certificate.""" + if not self.charm.model.get_relation(Config.TLS.TLS_PEER_RELATION): + return + + if param is None: + key = generate_private_key() + else: + key = self._parse_tls_file(param) + + sans = self.get_new_sans() + csr = generate_csr( + private_key=key, + subject=self._get_subject_name(), + organization=self._get_subject_name(), + sans=sans[SANS_DNS_KEY], + sans_ip=sans[SANS_IPS_KEY], + ) + self.set_tls_secret(internal, Config.TLS.SECRET_KEY_LABEL, key.decode("utf-8")) + self.set_tls_secret(internal, Config.TLS.SECRET_CSR_LABEL, csr.decode("utf-8")) + self.set_tls_secret(internal, Config.TLS.SECRET_CERT_LABEL, None) + + label = "int" if internal else "ext" + self.charm.unit_peer_data[f"{label}_certs_subject"] = self._get_subject_name() + self.charm.unit_peer_data[f"{label}_certs_subject"] = self._get_subject_name() + self.certs.request_certificate_creation(certificate_signing_request=csr) + self.set_waiting_for_cert_to_update(internal=internal, waiting=True) + + @staticmethod + def _parse_tls_file(raw_content: str) -> bytes: + """Parse TLS files from both plain text or base64 format.""" + if re.match(r"(-+(BEGIN|END) [A-Z ]+-+)", raw_content): + return ( + re.sub( + r"(-+(BEGIN|END) [A-Z ]+-+)", + "\\1", + raw_content, + ) + .rstrip() + .encode("utf-8") + ) + return base64.b64decode(raw_content) + + def _on_tls_relation_joined(self, event: RelationJoinedEvent) -> None: + """Request certificate when TLS relation joined.""" + if self.charm.is_role(Config.Role.MONGOS) and not self.charm.has_config_server(): + logger.info( + "mongos is not running (not integrated to config-server) deferring renewal of certificates." + ) + event.defer() + return + + if self.charm.upgrade_in_progress: + logger.warning( + "Enabling TLS is not supported during an upgrade. The charm may be in a broken, unrecoverable state." + ) + event.defer() + return + + self.request_certificate(None, internal=True) + self.request_certificate(None, internal=False) + + def _on_tls_relation_broken(self, event: RelationBrokenEvent) -> None: + """Disable TLS when TLS relation broken.""" + if not self.charm.db_initialised: + logger.info("Deferring %s. db is not initialised.", str(type(event))) + event.defer() + return + + if self.charm.upgrade_in_progress: + logger.warning( + "Disabling TLS is not supported during an upgrade. The charm may be in a broken, unrecoverable state." + ) + + logger.debug("Disabling external and internal TLS for unit: %s", self.charm.unit.name) + + for internal in [True, False]: + self.set_tls_secret(internal, Config.TLS.SECRET_CA_LABEL, None) + self.set_tls_secret(internal, Config.TLS.SECRET_CERT_LABEL, None) + self.set_tls_secret(internal, Config.TLS.SECRET_CHAIN_LABEL, None) + + if self.charm.is_role(Config.Role.CONFIG_SERVER): + self.charm.cluster.update_ca_secret(new_ca=None) + self.charm.config_server.update_ca_secret(new_ca=None) + + logger.info("Restarting mongod with TLS disabled.") + self.charm.status.set_and_share_status(MaintenanceStatus("disabling TLS")) + self.charm.delete_tls_certificate_from_workload() + self.charm.restart_charm_services() + self.charm.status.set_and_share_status(ActiveStatus()) + + def _on_certificate_available(self, event: CertificateAvailableEvent) -> None: + """Enable TLS when TLS certificate available.""" + if self.charm.is_role(Config.Role.MONGOS) and not self.charm.config_server_db: + logger.debug( + "mongos requires config-server in order to start, do not restart with TLS until integrated to config-server" + ) + event.defer() + return + + if not self.charm.db_initialised: + logger.info("Deferring %s. db is not initialised.", str(type(event))) + event.defer() + return + + int_csr = self.get_tls_secret(internal=True, label_name=Config.TLS.SECRET_CSR_LABEL) + ext_csr = self.get_tls_secret(internal=False, label_name=Config.TLS.SECRET_CSR_LABEL) + + if ext_csr and event.certificate_signing_request.rstrip() == ext_csr.rstrip(): + logger.debug("The external TLS certificate available.") + internal = False + elif int_csr and event.certificate_signing_request.rstrip() == int_csr.rstrip(): + logger.debug("The internal TLS certificate available.") + internal = True + else: + logger.error("An unknown certificate is available -- ignoring.") + return + + self.set_tls_secret( + internal, + Config.TLS.SECRET_CHAIN_LABEL, + "\n".join(event.chain) if event.chain is not None else None, + ) + self.set_tls_secret(internal, Config.TLS.SECRET_CERT_LABEL, event.certificate) + self.set_tls_secret(internal, Config.TLS.SECRET_CA_LABEL, event.ca) + self.set_waiting_for_cert_to_update(internal=internal, waiting=False) + + if self.charm.is_role(Config.Role.CONFIG_SERVER) and internal: + self.charm.cluster.update_ca_secret(new_ca=event.ca) + self.charm.config_server.update_ca_secret(new_ca=event.ca) + + if self.waiting_for_both_certs(): + logger.debug( + "Defer till both internal and external TLS certificates available to avoid second restart." + ) + event.defer() + return + + logger.info("Restarting mongod with TLS enabled.") + + self.charm.delete_tls_certificate_from_workload() + self.charm.push_tls_certificate_to_workload() + self.charm.status.set_and_share_status(MaintenanceStatus("enabling TLS")) + self.charm.restart_charm_services() + + if not self.charm.is_db_service_ready(): + self.charm.status.set_and_share_status(WaitingStatus("Waiting for MongoDB to start")) + elif self.charm.unit.status == WaitingStatus( + "Waiting for MongoDB to start" + ) or self.charm.unit.status == MaintenanceStatus("enabling TLS"): + # clear waiting status if db service is ready + self.charm.status.set_and_share_status(ActiveStatus()) + + def waiting_for_both_certs(self): + """Returns a boolean indicating whether additional certs are needed.""" + if not self.get_tls_secret(internal=True, label_name=Config.TLS.SECRET_CERT_LABEL): + logger.debug("Waiting for internal certificate.") + return True + if not self.get_tls_secret(internal=False, label_name=Config.TLS.SECRET_CERT_LABEL): + logger.debug("Waiting for external certificate.") + return True + + return False + + def _on_certificate_expiring(self, event: CertificateExpiringEvent) -> None: + """Request the new certificate when old certificate is expiring.""" + if self.charm.is_role(Config.Role.MONGOS) and not self.charm.has_config_server(): + logger.info( + "mongos is not running (not integrated to config-server) deferring renewal of certificates." + ) + event.defer() + return + + if ( + event.certificate.rstrip() + == self.get_tls_secret( + internal=False, label_name=Config.TLS.SECRET_CERT_LABEL + ).rstrip() + ): + logger.debug("The external TLS certificate expiring.") + internal = False + elif ( + event.certificate.rstrip() + == self.get_tls_secret(internal=True, label_name=Config.TLS.SECRET_CERT_LABEL).rstrip() + ): + logger.debug("The internal TLS certificate expiring.") + internal = True + else: + logger.error("An unknown certificate expiring.") + return + + logger.debug("Generating a new Certificate Signing Request.") + key = self.get_tls_secret(internal, Config.TLS.SECRET_KEY_LABEL).encode("utf-8") + old_csr = self.get_tls_secret(internal, Config.TLS.SECRET_CSR_LABEL).encode("utf-8") + sans = self.get_new_sans() + new_csr = generate_csr( + private_key=key, + subject=self._get_subject_name(), + organization=self._get_subject_name(), + sans=sans[SANS_DNS_KEY], + sans_ip=sans[SANS_IPS_KEY], + ) + logger.debug("Requesting a certificate renewal.") + + self.certs.request_certificate_renewal( + old_certificate_signing_request=old_csr, + new_certificate_signing_request=new_csr, + ) + + self.set_tls_secret(internal, Config.TLS.SECRET_CSR_LABEL, new_csr.decode("utf-8")) + + def get_new_sans(self) -> Dict: + """Create a list of DNS names for a MongoDB unit. + + Returns: + A list representing the hostnames of the MongoDB unit. + """ + unit_id = self.charm.unit.name.split("/")[1] + + sans = { + SANS_DNS_KEY: [ + f"{self.charm.app.name}-{unit_id}", + socket.getfqdn(), + "localhost", + f"{self.charm.app.name}-{unit_id}.{self.charm.app.name}-endpoints", + ], + SANS_IPS_KEY: [ + str(self.charm.model.get_binding(self.peer_relation).network.bind_address) + ], + } + + if self.charm.is_role(Config.Role.MONGOS) and self.charm.is_external_client: + sans[SANS_IPS_KEY].append( + self.charm.get_ext_mongos_host(self.charm.unit, incl_port=False) + ) + + return sans + + def get_current_sans(self, internal: bool) -> List[str] | None: + """Gets the current SANs for the unit cert.""" + # if unit has no certificates do not proceed. + if not self.is_tls_enabled(internal=internal): + return + + pem_file = self.get_tls_secret(internal, Config.TLS.SECRET_CERT_LABEL) + + try: + cert = x509.load_pem_x509_certificate(pem_file.encode(), default_backend()) + sans = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName).value + sans_ip = [str(san) for san in sans.get_values_for_type(x509.IPAddress)] + sans_dns = [str(san) for san in sans.get_values_for_type(x509.DNSName)] + except x509.ExtensionNotFound: + sans_ip = [] + sans_dns = [] + + return {SANS_IPS_KEY: sorted(sans_ip), SANS_DNS_KEY: sorted(sans_dns)} + + def get_tls_files(self, internal: bool) -> Tuple[Optional[str], Optional[str]]: + """Prepare TLS files in special MongoDB way. + + MongoDB needs two files: + — CA file should have a full chain. + — PEM file should have private key and certificate without certificate chain. + """ + scope = "internal" if internal else "external" + if not self.is_tls_enabled(internal): + logging.debug(f"TLS disabled for {scope}") + return None, None + logging.debug(f"TLS *enabled* for {scope}, fetching data for CA and PEM files ") + + ca = self.get_tls_secret(internal, Config.TLS.SECRET_CA_LABEL) + chain = self.get_tls_secret(internal, Config.TLS.SECRET_CHAIN_LABEL) + ca_file = chain if chain else ca + + key = self.get_tls_secret(internal, Config.TLS.SECRET_KEY_LABEL) + cert = self.get_tls_secret(internal, Config.TLS.SECRET_CERT_LABEL) + pem_file = key + if cert: + pem_file = key + "\n" + cert if key else cert + + return ca_file, pem_file + + def set_tls_secret(self, internal: bool, label_name: str, contents: str) -> None: + """Sets TLS secret, based on whether or not it is related to internal connections.""" + scope = "int" if internal else "ext" + label_name = f"{scope}-{label_name}" + self.charm.set_secret(UNIT_SCOPE, label_name, contents) + + def get_tls_secret(self, internal: bool, label_name: str) -> str: + """Gets TLS secret, based on whether or not it is related to internal connections.""" + scope = "int" if internal else "ext" + label_name = f"{scope}-{label_name}" + return self.charm.get_secret(UNIT_SCOPE, label_name) + + def _get_subject_name(self) -> str: + """Generate the subject name for CSR.""" + # In sharded MongoDB deployments it is a requirement that all subject names match across + # all cluster components. The config-server name is the source of truth across mongos and + # shard deployments. + if not self.charm.is_role(Config.Role.CONFIG_SERVER): + # until integrated with config-server use current app name as + # subject name + return self.charm.get_config_server_name() or self.charm.app.name + + return self.charm.app.name + + def is_set_waiting_for_cert_to_update( + self, + internal=False, + ) -> bool: + """Returns True we are waiting for a cert to update.""" + scope = "int" if internal else "ext" + label_name = f"{scope}-{WAIT_CERT_UPDATE}" + + return json.loads(self.charm.unit_peer_data.get(label_name, "false")) + + def set_waiting_for_cert_to_update( + self, + waiting: bool, + internal: bool, + ) -> None: + """Sets a boolean indicator, for whether or not we are waiting for a cert to update.""" + scope = "int" if internal else "ext" + label_name = f"{scope}-{WAIT_CERT_UPDATE}" + self.charm.unit_peer_data[label_name] = json.dumps(waiting) diff --git a/src/charm.py b/src/charm.py index 7fbe2355..2183fb54 100755 --- a/src/charm.py +++ b/src/charm.py @@ -24,7 +24,7 @@ from charms.mongodb.v0.mongo import MongoConfiguration, MongoConnection from charms.mongos.v0.set_status import MongosStatusHandler from charms.mongodb.v1.mongodb_provider import MongoDBProvider, FailedToGetHostsError -from charms.mongodb.v0.mongodb_tls import MongoDBTLS +from charms.mongodb.v1.mongodb_tls import MongoDBTLS from charms.mongodb.v0.mongodb_secrets import SecretCache from charms.mongodb.v0.mongodb_secrets import generate_secret_label from charms.mongodb.v1.helpers import get_mongos_args