From 65e8a52bbf21fba73167a60e1ba16e27bff35816 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sinclert=20P=C3=A9rez?= Date: Wed, 15 Apr 2026 11:03:58 +0200 Subject: [PATCH 1/9] [Docs] Refactor TLS docs --- docs/how-to/enable-tls.md | 112 --------------------------------- docs/how-to/index.md | 2 +- docs/how-to/tls/disable-tls.md | 54 ++++++++++++++++ docs/how-to/tls/enable-tls.md | 58 +++++++++++++++++ docs/how-to/tls/index.md | 19 ++++++ docs/tutorial.md | 16 ++--- 6 files changed, 140 insertions(+), 121 deletions(-) delete mode 100644 docs/how-to/enable-tls.md create mode 100644 docs/how-to/tls/disable-tls.md create mode 100644 docs/how-to/tls/enable-tls.md create mode 100644 docs/how-to/tls/index.md diff --git a/docs/how-to/enable-tls.md b/docs/how-to/enable-tls.md deleted file mode 100644 index 55641cef7..000000000 --- a/docs/how-to/enable-tls.md +++ /dev/null @@ -1,112 +0,0 @@ ---- -myst: - html_meta: - description: "Learn how to enable TLS encryption for Charmed MySQL using the self-signed-certificates operator, including deployment and key rotation steps." ---- - -(enable-tls)= -# How to enable TLS encryption - -This guide describes how to enable TLS using the [`self-signed-certificates` operator](https://github.com/canonical/self-signed-certificates-operator) as an example. - -```{caution} -**[Self-signed certificates](https://en.wikipedia.org/wiki/Self-signed_certificate) are not recommended for a production environment.** - -Check [this guide](https://discourse.charmhub.io/t/11664) for an overview of the TLS certificates charms available. -``` - -## Enable TLS - -First, deploy the TLS charm: - -```shell -juju deploy self-signed-certificates -``` - -To enable TLS, integrate it with your MySQL application: - -````{tab-set} -```{tab-item} VM -:sync: vm - - juju integrate self-signed-certificates mysql -``` - -```{tab-item} K8s -:sync: k8s - - juju integrate self-signed-certificates mysql-k8s -``` -```` - -## Manage keys - -Updates to private keys for certificate signing requests (CSR) can be made via the `set-tls-private-key` action. Note that passing keys to external/internal keys should *only be done with* `base64 -w0`, *not* `cat`. - -With three replicas, the following schema should be followed. - -Generate a shared internal (private) key: - -```shell -openssl genrsa -out internal-key.pem 3072 -``` - -Apply the newly generated internal key on each `juju` unit: - -````{tab-set} -```{tab-item} VM -:sync: vm - - juju run mysql/0 set-tls-private-key "internal-key=$(base64 -w0 internal-key.pem)" - juju run mysql/1 set-tls-private-key "internal-key=$(base64 -w0 internal-key.pem)" - juju run mysql/2 set-tls-private-key "internal-key=$(base64 -w0 internal-key.pem)" - -``` - -```{tab-item} K8s -:sync: k8s - - juju run mysql-k8s/0 set-tls-private-key "internal-key=$(base64 -w0 internal-key.pem)" - juju run mysql-k8s/1 set-tls-private-key "internal-key=$(base64 -w0 internal-key.pem)" - juju run mysql-k8s/2 set-tls-private-key "internal-key=$(base64 -w0 internal-key.pem)" -``` -```` - -Updates can also be done with auto-generated keys: - -````{tab-set} -```{tab-item} VM -:sync: vm - - juju run mysql/0 set-tls-private-key - juju run mysql/1 set-tls-private-key - juju run mysql/2 set-tls-private-key -``` - -```{tab-item} K8s -:sync: k8s - - juju run mysql-k8s/0 set-tls-private-key - juju run mysql-k8s/1 set-tls-private-key - juju run mysql-k8s/2 set-tls-private-key -``` -```` - -## Disable TLS - -Disable TLS by removing the integration: - -````{tab-set} -```{tab-item} VM -:sync: vm - - juju remove-relation self-signed-certificates mysql -``` - -```{tab-item} K8s -:sync: k8s - - juju remove-relation self-signed-certificates mysql-k8s -``` -```` - diff --git a/docs/how-to/index.md b/docs/how-to/index.md index 8e1b3c412..819ef8532 100644 --- a/docs/how-to/index.md +++ b/docs/how-to/index.md @@ -40,7 +40,7 @@ Networking and encryption: :titlesonly: :maxdepth: 2 -Enable TLS +TLS encryption External network access ``` diff --git a/docs/how-to/tls/disable-tls.md b/docs/how-to/tls/disable-tls.md new file mode 100644 index 000000000..92d89a603 --- /dev/null +++ b/docs/how-to/tls/disable-tls.md @@ -0,0 +1,54 @@ +--- +myst: + html_meta: + description: "Learn how to disable TLS encryption for Charmed MySQL using the self-signed-certificates operator." +--- + +(disable-tls)= +# How to disable TLS + +To follow this guide, you need to have a running Charmed MySQL cluster with TLS enabled. +See {ref}`enable-tls` for more information. In general, to disable encryption with TLS, +remove the relation between Charmed MySQL and the TLS provider. + +````{tab-set} +```{tab-item} VM +:sync: vm + + juju status --relations + + > Integration provider Requirer Interface Type Message + > mysql:database-peers mysql:database-peers mysql_peers peer + > mysql:restart mysql:restart rolling_op peer + > self-signed-certificates:certificates mysql:client-certificates tls-certificates regular +``` + +```{tab-item} K8s +:sync: k8s + + juju status --relations + + > Integration provider Requirer Interface Type Message + > mysql-k8s:database-peers mysql-k8s:database-peers mysql_peers peer + > mysql-k8s:restart mysql-k8s:restart rolling_op peer + > self-signed-certificates:certificates mysql-k8s:client-certificates tls-certificates regular +``` +```` + +## Disable client-to-server encryption + +Separate the certificates charm and the Charmed MySQL application on the `client-certificates` endpoint: + +````{tab-set} +```{tab-item} VM +:sync: vm + + juju remove-relation self-signed-certificates mysql:client-certificates +``` + +```{tab-item} K8s +:sync: k8s + + juju remove-relation self-signed-certificates mysql-k8s:client-certificates +``` +```` diff --git a/docs/how-to/tls/enable-tls.md b/docs/how-to/tls/enable-tls.md new file mode 100644 index 000000000..261676306 --- /dev/null +++ b/docs/how-to/tls/enable-tls.md @@ -0,0 +1,58 @@ +--- +myst: + html_meta: + description: "Learn how to enable TLS encryption for Charmed MySQL using the self-signed-certificates operator." +--- + +(enable-tls)= +# How to enable TLS + +Charmed MySQL provides a secure transport layer for both **client-server** and **peer-to-peer** communication, +providing a simple way of enabling TLS encryption for both types. + +Peer-to-peer +: All communication between members in the cluster will be encrypted. + +Client-to-server +: The clients can verify the server identity and provide transport security. + + +## Deploy a TLS provider + +This guide describes how to enable TLS using the [`self-signed-certificates` operator](https://github.com/canonical/self-signed-certificates-operator). + +```{caution} +**[Self-signed certificates](https://en.wikipedia.org/wiki/Self-signed_certificate) are not recommended for a production environment.** + +Check [this guide](https://discourse.charmhub.io/t/11664) for an overview of the TLS certificates charms available. +``` + +```shell +juju deploy self-signed-certificates --channel 1/stable +``` + +## Enable client-to-server encryption + +Integrate the certificates charm with the Charmed MySQL application on the `client-certificates` endpoint: + +````{tab-set} +```{tab-item} VM +:sync: vm + + juju integrate self-signed-certificates mysql:client-certificates +``` + +```{tab-item} K8s +:sync: k8s + + juju integrate self-signed-certificates mysql-k8s:client-certificates +``` +```` + +## Certificate expiration and rotation + +Charmed MySQL provides full automation of certificate rotation. + +As soon as new certificates are issued by the TLS provider, Charmed MySQL will replace the expiring certificate with the +renewed one on each unit. In case of CA certificates, it will restart the units in rolling fashion to enable the updated +CA certificate while maintaining availability during the process. diff --git a/docs/how-to/tls/index.md b/docs/how-to/tls/index.md new file mode 100644 index 000000000..7ee257146 --- /dev/null +++ b/docs/how-to/tls/index.md @@ -0,0 +1,19 @@ +--- +myst: + html_meta: + description: "Learn how to manage TLS encryption for Charmed MySQL using the self-signed-certificates operator." +--- + +# TLS encryption + +Transport Layer Security (TLS) plays a crucial role in securing database communications. +Just as it protects web traffic, TLS encrypts the data transmitted between database clients and servers, +preventing unauthorized access and ensuring confidentiality. + +```{toctree} +:titlesonly: +:maxdepth: 2 + +Enable TLS +Disable TLS +``` diff --git a/docs/tutorial.md b/docs/tutorial.md index cee0aab32..819dc3e6a 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -828,9 +828,9 @@ Wait until `self-signed-certificates` is up and active, using `juju status --wat Model Controller Cloud/Region Version SLA Timestamp tutorial overlord localhost/localhost 3.6.13 unsupported 23:04:02+01:00 -App Version Status Scale Charm Channel Rev Exposed Message -mysql 8.4.7 active 2 mysql 8.4/edge no -self-signed-certificates active 1 self-signed-certificates 1/stable 77 no +App Version Status Scale Charm Channel Rev Exposed Message +mysql 8.4.7 active 2 mysql 8.4/edge no +self-signed-certificates active 1 self-signed-certificates 1/stable 588 no Unit Workload Agent Machine Public address Ports Message mysql/0* active idle 0 10.234.188.135 Primary @@ -853,7 +853,7 @@ tutorial overlord microk8s/localhost 3.6.13 unsupported 23:04:02+01:00 App Version Status Scale Charm Channel Rev Address Exposed Message mysql-k8s 8.4.7 active 2 mysql-k8s 8.4/edge 10.152.183.234 no -self-signed-certificates active 1 self-signed-certificates 1/stable 72 10.152.183.76 no +self-signed-certificates active 1 self-signed-certificates 1/stable 588 10.152.183.76 no Unit Workload Agent Address Ports Message mysql-k8s/0* active idle 10.1.84.74 @@ -873,7 +873,7 @@ To enable TLS on Charmed MySQL, integrate the two applications: :user: ubuntu :host: my-vm -juju integrate mysql self-signed-certificates +juju integrate mysql:client-certificates self-signed-certificates ``` ```` @@ -884,7 +884,7 @@ juju integrate mysql self-signed-certificates :user: ubuntu :host: my-vm -juju integrate mysql-k8s self-signed-certificates +juju integrate mysql-k8s:client-certificates self-signed-certificates ``` ```` ````` @@ -933,7 +933,7 @@ To remove the external TLS and return to the locally generate one, remove the in :user: ubuntu :host: my-vm -juju remove-relation mysql self-signed-certificates +juju remove-relation mysql:client-certificates self-signed-certificates ``` ```` @@ -944,7 +944,7 @@ juju remove-relation mysql self-signed-certificates :user: ubuntu :host: my-vm -juju remove-relation mysql-k8s self-signed-certificates +juju remove-relation mysql-k8s:client-certificates self-signed-certificates ``` ```` ````` From 2cf8f994b0e2cb7905f15e8e84a5361a9bdb86d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sinclert=20P=C3=A9rez?= Date: Wed, 15 Apr 2026 11:07:00 +0200 Subject: [PATCH 2/9] [K8s] Replace TLS charmlib by PyPi lib --- .../v2/tls_certificates.py | 1959 ----------------- kubernetes/poetry.lock | 27 +- kubernetes/pyproject.toml | 1 + 3 files changed, 23 insertions(+), 1964 deletions(-) delete mode 100644 kubernetes/lib/charms/tls_certificates_interface/v2/tls_certificates.py diff --git a/kubernetes/lib/charms/tls_certificates_interface/v2/tls_certificates.py b/kubernetes/lib/charms/tls_certificates_interface/v2/tls_certificates.py deleted file mode 100644 index c232362fe..000000000 --- a/kubernetes/lib/charms/tls_certificates_interface/v2/tls_certificates.py +++ /dev/null @@ -1,1959 +0,0 @@ -# Copyright 2021 Canonical Ltd. -# See LICENSE file for licensing details. - - -"""Library for the tls-certificates relation. - -This library contains the Requires and Provides classes for handling the tls-certificates -interface. - -## Getting Started -From a charm directory, fetch the library using `charmcraft`: - -```shell -charmcraft fetch-lib charms.tls_certificates_interface.v2.tls_certificates -``` - -Add the following libraries to the charm's `requirements.txt` file: -- jsonschema -- cryptography - -Add the following section to the charm's `charmcraft.yaml` file: -```yaml -parts: - charm: - build-packages: - - libffi-dev - - libssl-dev - - rustc - - cargo -``` - -### Provider charm -The provider charm is the charm providing certificates to another charm that requires them. In -this example, the provider charm is storing its private key using a peer relation interface called -`replicas`. - -Example: -```python -from charms.tls_certificates_interface.v2.tls_certificates import ( - CertificateCreationRequestEvent, - CertificateRevocationRequestEvent, - TLSCertificatesProvidesV2, - generate_private_key, -) -from ops.charm import CharmBase, InstallEvent -from ops.main import main -from ops.model import ActiveStatus, WaitingStatus - - -def generate_ca(private_key: bytes, subject: str) -> str: - return "whatever ca content" - - -def generate_certificate(ca: str, private_key: str, csr: str) -> str: - return "Whatever certificate" - - -class ExampleProviderCharm(CharmBase): - - def __init__(self, *args): - super().__init__(*args) - self.certificates = TLSCertificatesProvidesV2(self, "certificates") - self.framework.observe( - self.certificates.on.certificate_request, - self._on_certificate_request - ) - self.framework.observe( - self.certificates.on.certificate_revocation_request, - self._on_certificate_revocation_request - ) - self.framework.observe(self.on.install, self._on_install) - - def _on_install(self, event: InstallEvent) -> None: - private_key_password = b"banana" - private_key = generate_private_key(password=private_key_password) - ca_certificate = generate_ca(private_key=private_key, subject="whatever") - replicas_relation = self.model.get_relation("replicas") - if not replicas_relation: - self.unit.status = WaitingStatus("Waiting for peer relation to be created") - event.defer() - return - replicas_relation.data[self.app].update( - { - "private_key_password": "banana", - "private_key": private_key, - "ca_certificate": ca_certificate, - } - ) - self.unit.status = ActiveStatus() - - def _on_certificate_request(self, event: CertificateCreationRequestEvent) -> None: - replicas_relation = self.model.get_relation("replicas") - if not replicas_relation: - self.unit.status = WaitingStatus("Waiting for peer relation to be created") - event.defer() - return - ca_certificate = replicas_relation.data[self.app].get("ca_certificate") - private_key = replicas_relation.data[self.app].get("private_key") - certificate = generate_certificate( - ca=ca_certificate, - private_key=private_key, - csr=event.certificate_signing_request, - ) - - self.certificates.set_relation_certificate( - certificate=certificate, - certificate_signing_request=event.certificate_signing_request, - ca=ca_certificate, - chain=[ca_certificate, certificate], - relation_id=event.relation_id, - ) - - def _on_certificate_revocation_request(self, event: CertificateRevocationRequestEvent) -> None: - # Do what you want to do with this information - pass - - -if __name__ == "__main__": - main(ExampleProviderCharm) -``` - -### Requirer charm -The requirer charm is the charm requiring certificates from another charm that provides them. In -this example, the requirer charm is storing its certificates using a peer relation interface called -`replicas`. - -Example: -```python -from charms.tls_certificates_interface.v2.tls_certificates import ( - CertificateAvailableEvent, - CertificateExpiringEvent, - CertificateRevokedEvent, - TLSCertificatesRequiresV2, - generate_csr, - generate_private_key, -) -from ops.charm import CharmBase, RelationJoinedEvent -from ops.main import main -from ops.model import ActiveStatus, WaitingStatus -from typing import Union - - -class ExampleRequirerCharm(CharmBase): - - def __init__(self, *args): - super().__init__(*args) - self.cert_subject = "whatever" - self.certificates = TLSCertificatesRequiresV2(self, "certificates") - self.framework.observe(self.on.install, self._on_install) - self.framework.observe( - self.on.certificates_relation_joined, self._on_certificates_relation_joined - ) - self.framework.observe( - self.certificates.on.certificate_available, self._on_certificate_available - ) - self.framework.observe( - self.certificates.on.certificate_expiring, self._on_certificate_expiring - ) - self.framework.observe( - self.certificates.on.certificate_invalidated, self._on_certificate_invalidated - ) - self.framework.observe( - self.certificates.on.all_certificates_invalidated, - self._on_all_certificates_invalidated - ) - - def _on_install(self, event) -> None: - private_key_password = b"banana" - private_key = generate_private_key(password=private_key_password) - replicas_relation = self.model.get_relation("replicas") - if not replicas_relation: - self.unit.status = WaitingStatus("Waiting for peer relation to be created") - event.defer() - return - replicas_relation.data[self.app].update( - {"private_key_password": "banana", "private_key": private_key.decode()} - ) - - def _on_certificates_relation_joined(self, event: RelationJoinedEvent) -> None: - replicas_relation = self.model.get_relation("replicas") - if not replicas_relation: - self.unit.status = WaitingStatus("Waiting for peer relation to be created") - event.defer() - return - private_key_password = replicas_relation.data[self.app].get("private_key_password") - private_key = replicas_relation.data[self.app].get("private_key") - csr = generate_csr( - private_key=private_key.encode(), - private_key_password=private_key_password.encode(), - subject=self.cert_subject, - ) - replicas_relation.data[self.app].update({"csr": csr.decode()}) - self.certificates.request_certificate_creation(certificate_signing_request=csr) - - def _on_certificate_available(self, event: CertificateAvailableEvent) -> None: - replicas_relation = self.model.get_relation("replicas") - if not replicas_relation: - self.unit.status = WaitingStatus("Waiting for peer relation to be created") - event.defer() - return - replicas_relation.data[self.app].update({"certificate": event.certificate}) - replicas_relation.data[self.app].update({"ca": event.ca}) - replicas_relation.data[self.app].update({"chain": event.chain}) - self.unit.status = ActiveStatus() - - def _on_certificate_expiring( - self, event: Union[CertificateExpiringEvent, CertificateInvalidatedEvent] - ) -> None: - replicas_relation = self.model.get_relation("replicas") - if not replicas_relation: - self.unit.status = WaitingStatus("Waiting for peer relation to be created") - event.defer() - return - old_csr = replicas_relation.data[self.app].get("csr") - private_key_password = replicas_relation.data[self.app].get("private_key_password") - private_key = replicas_relation.data[self.app].get("private_key") - new_csr = generate_csr( - private_key=private_key.encode(), - private_key_password=private_key_password.encode(), - subject=self.cert_subject, - ) - self.certificates.request_certificate_renewal( - old_certificate_signing_request=old_csr, - new_certificate_signing_request=new_csr, - ) - replicas_relation.data[self.app].update({"csr": new_csr.decode()}) - - def _certificate_revoked(self) -> None: - old_csr = replicas_relation.data[self.app].get("csr") - private_key_password = replicas_relation.data[self.app].get("private_key_password") - private_key = replicas_relation.data[self.app].get("private_key") - new_csr = generate_csr( - private_key=private_key.encode(), - private_key_password=private_key_password.encode(), - subject=self.cert_subject, - ) - self.certificates.request_certificate_renewal( - old_certificate_signing_request=old_csr, - new_certificate_signing_request=new_csr, - ) - replicas_relation.data[self.app].update({"csr": new_csr.decode()}) - replicas_relation.data[self.app].pop("certificate") - replicas_relation.data[self.app].pop("ca") - replicas_relation.data[self.app].pop("chain") - self.unit.status = WaitingStatus("Waiting for new certificate") - - def _on_certificate_invalidated(self, event: CertificateInvalidatedEvent) -> None: - replicas_relation = self.model.get_relation("replicas") - if not replicas_relation: - self.unit.status = WaitingStatus("Waiting for peer relation to be created") - event.defer() - return - if event.reason == "revoked": - self._certificate_revoked() - if event.reason == "expired": - self._on_certificate_expiring(event) - - def _on_all_certificates_invalidated(self, event: AllCertificatesInvalidatedEvent) -> None: - # Do what you want with this information, probably remove all certificates. - pass - - -if __name__ == "__main__": - main(ExampleRequirerCharm) -``` - -You can relate both charms by running: - -```bash -juju relate -``` - -""" # noqa: D405, D410, D411, D214, D416 - -import copy -import json -import logging -import uuid -from contextlib import suppress -from datetime import datetime, timedelta, timezone -from ipaddress import IPv4Address -from typing import Any, Dict, List, Literal, Optional, Union - -from cryptography import x509 -from cryptography.hazmat._oid import ExtensionOID -from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import rsa -from cryptography.hazmat.primitives.serialization import pkcs12 -from jsonschema import exceptions, validate -from ops.charm import ( - CharmBase, - CharmEvents, - RelationBrokenEvent, - RelationChangedEvent, - SecretExpiredEvent, - UpdateStatusEvent, -) -from ops.framework import EventBase, EventSource, Handle, Object -from ops.jujuversion import JujuVersion -from ops.model import ModelError, Relation, RelationDataContent, SecretNotFoundError - -# The unique Charmhub library identifier, never change it -LIBID = "afd8c2bccf834997afce12c2706d2ede" - -# Increment this major API version when introducing breaking changes -LIBAPI = 2 - -# Increment this PATCH version before using `charmcraft publish-lib` or reset -# to 0 if you are raising the major API version -LIBPATCH = 29 - -PYDEPS = ["cryptography", "jsonschema"] - -REQUIRER_JSON_SCHEMA = { - "$schema": "http://json-schema.org/draft-04/schema#", - "$id": "https://canonical.github.io/charm-relation-interfaces/interfaces/tls_certificates/v1/schemas/requirer.json", - "type": "object", - "title": "`tls_certificates` requirer root schema", - "description": "The `tls_certificates` root schema comprises the entire requirer databag for this interface.", # noqa: E501 - "examples": [ - { - "certificate_signing_requests": [ - { - "certificate_signing_request": "-----BEGIN CERTIFICATE REQUEST-----\\nMIICWjCCAUICAQAwFTETMBEGA1UEAwwKYmFuYW5hLmNvbTCCASIwDQYJKoZIhvcN\\nAQEBBQADggEPADCCAQoCggEBANWlx9wE6cW7Jkb4DZZDOZoEjk1eDBMJ+8R4pyKp\\nFBeHMl1SQSDt6rAWsrfL3KOGiIHqrRY0B5H6c51L8LDuVrJG0bPmyQ6rsBo3gVke\\nDSivfSLtGvHtp8lwYnIunF8r858uYmblAR0tdXQNmnQvm+6GERvURQ6sxpgZ7iLC\\npPKDoPt+4GKWL10FWf0i82FgxWC2KqRZUtNbgKETQuARLig7etBmCnh20zmynorA\\ncY7vrpTPAaeQpGLNqqYvKV9W6yWVY08V+nqARrFrjk3vSioZSu8ZJUdZ4d9++SGl\\nbH7A6e77YDkX9i/dQ3Pa/iDtWO3tXS2MvgoxX1iSWlGNOHcCAwEAAaAAMA0GCSqG\\nSIb3DQEBCwUAA4IBAQCW1fKcHessy/ZhnIwAtSLznZeZNH8LTVOzkhVd4HA7EJW+\\nKVLBx8DnN7L3V2/uPJfHiOg4Rx7fi7LkJPegl3SCqJZ0N5bQS/KvDTCyLG+9E8Y+\\n7wqCmWiXaH1devimXZvazilu4IC2dSks2D8DPWHgsOdVks9bme8J3KjdNMQudegc\\newWZZ1Dtbd+Rn7cpKU3jURMwm4fRwGxbJ7iT5fkLlPBlyM/yFEik4SmQxFYrZCQg\\n0f3v4kBefTh5yclPy5tEH+8G0LMsbbo3dJ5mPKpAShi0QEKDLd7eR1R/712lYTK4\\ndi4XaEfqERgy68O4rvb4PGlJeRGS7AmL7Ss8wfAq\\n-----END CERTIFICATE REQUEST-----\\n" # noqa: E501 - }, - { - "certificate_signing_request": "-----BEGIN CERTIFICATE REQUEST-----\\nMIICWjCCAUICAQAwFTETMBEGA1UEAwwKYmFuYW5hLmNvbTCCASIwDQYJKoZIhvcN\\nAQEBBQADggEPADCCAQoCggEBAMk3raaX803cHvzlBF9LC7KORT46z4VjyU5PIaMb\\nQLIDgYKFYI0n5hf2Ra4FAHvOvEmW7bjNlHORFEmvnpcU5kPMNUyKFMTaC8LGmN8z\\nUBH3aK+0+FRvY4afn9tgj5435WqOG9QdoDJ0TJkjJbJI9M70UOgL711oU7ql6HxU\\n4d2ydFK9xAHrBwziNHgNZ72L95s4gLTXf0fAHYf15mDA9U5yc+YDubCKgTXzVySQ\\nUx73VCJLfC/XkZIh559IrnRv5G9fu6BMLEuBwAz6QAO4+/XidbKWN4r2XSq5qX4n\\n6EPQQWP8/nd4myq1kbg6Q8w68L/0YdfjCmbyf2TuoWeImdUCAwEAAaAAMA0GCSqG\\nSIb3DQEBCwUAA4IBAQBIdwraBvpYo/rl5MH1+1Um6HRg4gOdQPY5WcJy9B9tgzJz\\nittRSlRGTnhyIo6fHgq9KHrmUthNe8mMTDailKFeaqkVNVvk7l0d1/B90Kz6OfmD\\nxN0qjW53oP7y3QB5FFBM8DjqjmUnz5UePKoX4AKkDyrKWxMwGX5RoET8c/y0y9jp\\nvSq3Wh5UpaZdWbe1oVY8CqMVUEVQL2DPjtopxXFz2qACwsXkQZxWmjvZnRiP8nP8\\nbdFaEuh9Q6rZ2QdZDEtrU4AodPU3NaukFr5KlTUQt3w/cl+5//zils6G5zUWJ2pN\\ng7+t9PTvXHRkH+LnwaVnmsBFU2e05qADQbfIn7JA\\n-----END CERTIFICATE REQUEST-----\\n" # noqa: E501 - }, - ] - } - ], - "properties": { - "certificate_signing_requests": { - "type": "array", - "items": { - "type": "object", - "properties": { - "certificate_signing_request": {"type": "string"}, - "ca": {"type": "boolean"}, - }, - "required": ["certificate_signing_request"], - }, - } - }, - "required": ["certificate_signing_requests"], - "additionalProperties": True, -} - -PROVIDER_JSON_SCHEMA = { - "$schema": "http://json-schema.org/draft-04/schema#", - "$id": "https://canonical.github.io/charm-relation-interfaces/interfaces/tls_certificates/v1/schemas/provider.json", - "type": "object", - "title": "`tls_certificates` provider root schema", - "description": "The `tls_certificates` root schema comprises the entire provider databag for this interface.", # noqa: E501 - "examples": [ - { - "certificates": [ - { - "ca": "-----BEGIN CERTIFICATE-----\\nMIIDJTCCAg2gAwIBAgIUMsSK+4FGCjW6sL/EXMSxColmKw8wDQYJKoZIhvcNAQEL\\nBQAwIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdoYXRldmVyMB4XDTIyMDcyOTIx\\nMTgyN1oXDTIzMDcyOTIxMTgyN1owIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdo\\nYXRldmVyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA55N9DkgFWbJ/\\naqcdQhso7n1kFvt6j/fL1tJBvRubkiFMQJnZFtekfalN6FfRtA3jq+nx8o49e+7t\\nLCKT0xQ+wufXfOnxv6/if6HMhHTiCNPOCeztUgQ2+dfNwRhYYgB1P93wkUVjwudK\\n13qHTTZ6NtEF6EzOqhOCe6zxq6wrr422+ZqCvcggeQ5tW9xSd/8O1vNID/0MTKpy\\nET3drDtBfHmiUEIBR3T3tcy6QsIe4Rz/2sDinAcM3j7sG8uY6drh8jY3PWar9til\\nv2l4qDYSU8Qm5856AB1FVZRLRJkLxZYZNgreShAIYgEd0mcyI2EO/UvKxsIcxsXc\\nd45GhGpKkwIDAQABo1cwVTAfBgNVHQ4EGAQWBBRXBrXKh3p/aFdQjUcT/UcvICBL\\nODAhBgNVHSMEGjAYgBYEFFcGtcqHen9oV1CNRxP9Ry8gIEs4MA8GA1UdEwEB/wQF\\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAGmCEvcoFUrT9e133SHkgF/ZAgzeIziO\\nBjfAdU4fvAVTVfzaPm0yBnGqzcHyacCzbZjKQpaKVgc5e6IaqAQtf6cZJSCiJGhS\\nJYeosWrj3dahLOUAMrXRr8G/Ybcacoqc+osKaRa2p71cC3V6u2VvcHRV7HDFGJU7\\noijbdB+WhqET6Txe67rxZCJG9Ez3EOejBJBl2PJPpy7m1Ml4RR+E8YHNzB0lcBzc\\nEoiJKlDfKSO14E2CPDonnUoWBJWjEvJys3tbvKzsRj2fnLilytPFU0gH3cEjCopi\\nzFoWRdaRuNHYCqlBmso1JFDl8h4fMmglxGNKnKRar0WeGyxb4xXBGpI=\\n-----END CERTIFICATE-----\\n", # noqa: E501 - "chain": [ - "-----BEGIN CERTIFICATE-----\\nMIIDJTCCAg2gAwIBAgIUMsSK+4FGCjW6sL/EXMSxColmKw8wDQYJKoZIhvcNAQEL\\nBQAwIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdoYXRldmVyMB4XDTIyMDcyOTIx\\nMTgyN1oXDTIzMDcyOTIxMTgyN1owIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdo\\nYXRldmVyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA55N9DkgFWbJ/\\naqcdQhso7n1kFvt6j/fL1tJBvRubkiFMQJnZFtekfalN6FfRtA3jq+nx8o49e+7t\\nLCKT0xQ+wufXfOnxv6/if6HMhHTiCNPOCeztUgQ2+dfNwRhYYgB1P93wkUVjwudK\\n13qHTTZ6NtEF6EzOqhOCe6zxq6wrr422+ZqCvcggeQ5tW9xSd/8O1vNID/0MTKpy\\nET3drDtBfHmiUEIBR3T3tcy6QsIe4Rz/2sDinAcM3j7sG8uY6drh8jY3PWar9til\\nv2l4qDYSU8Qm5856AB1FVZRLRJkLxZYZNgreShAIYgEd0mcyI2EO/UvKxsIcxsXc\\nd45GhGpKkwIDAQABo1cwVTAfBgNVHQ4EGAQWBBRXBrXKh3p/aFdQjUcT/UcvICBL\\nODAhBgNVHSMEGjAYgBYEFFcGtcqHen9oV1CNRxP9Ry8gIEs4MA8GA1UdEwEB/wQF\\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAGmCEvcoFUrT9e133SHkgF/ZAgzeIziO\\nBjfAdU4fvAVTVfzaPm0yBnGqzcHyacCzbZjKQpaKVgc5e6IaqAQtf6cZJSCiJGhS\\nJYeosWrj3dahLOUAMrXRr8G/Ybcacoqc+osKaRa2p71cC3V6u2VvcHRV7HDFGJU7\\noijbdB+WhqET6Txe67rxZCJG9Ez3EOejBJBl2PJPpy7m1Ml4RR+E8YHNzB0lcBzc\\nEoiJKlDfKSO14E2CPDonnUoWBJWjEvJys3tbvKzsRj2fnLilytPFU0gH3cEjCopi\\nzFoWRdaRuNHYCqlBmso1JFDl8h4fMmglxGNKnKRar0WeGyxb4xXBGpI=\\n-----END CERTIFICATE-----\\n" # noqa: E501, W505 - ], - "certificate_signing_request": "-----BEGIN CERTIFICATE REQUEST-----\nMIICWjCCAUICAQAwFTETMBEGA1UEAwwKYmFuYW5hLmNvbTCCASIwDQYJKoZIhvcN\nAQEBBQADggEPADCCAQoCggEBANWlx9wE6cW7Jkb4DZZDOZoEjk1eDBMJ+8R4pyKp\nFBeHMl1SQSDt6rAWsrfL3KOGiIHqrRY0B5H6c51L8LDuVrJG0bPmyQ6rsBo3gVke\nDSivfSLtGvHtp8lwYnIunF8r858uYmblAR0tdXQNmnQvm+6GERvURQ6sxpgZ7iLC\npPKDoPt+4GKWL10FWf0i82FgxWC2KqRZUtNbgKETQuARLig7etBmCnh20zmynorA\ncY7vrpTPAaeQpGLNqqYvKV9W6yWVY08V+nqARrFrjk3vSioZSu8ZJUdZ4d9++SGl\nbH7A6e77YDkX9i/dQ3Pa/iDtWO3tXS2MvgoxX1iSWlGNOHcCAwEAAaAAMA0GCSqG\nSIb3DQEBCwUAA4IBAQCW1fKcHessy/ZhnIwAtSLznZeZNH8LTVOzkhVd4HA7EJW+\nKVLBx8DnN7L3V2/uPJfHiOg4Rx7fi7LkJPegl3SCqJZ0N5bQS/KvDTCyLG+9E8Y+\n7wqCmWiXaH1devimXZvazilu4IC2dSks2D8DPWHgsOdVks9bme8J3KjdNMQudegc\newWZZ1Dtbd+Rn7cpKU3jURMwm4fRwGxbJ7iT5fkLlPBlyM/yFEik4SmQxFYrZCQg\n0f3v4kBefTh5yclPy5tEH+8G0LMsbbo3dJ5mPKpAShi0QEKDLd7eR1R/712lYTK4\ndi4XaEfqERgy68O4rvb4PGlJeRGS7AmL7Ss8wfAq\n-----END CERTIFICATE REQUEST-----\n", # noqa: E501 - "certificate": "-----BEGIN CERTIFICATE-----\nMIICvDCCAaQCFFPAOD7utDTsgFrm0vS4We18OcnKMA0GCSqGSIb3DQEBCwUAMCAx\nCzAJBgNVBAYTAlVTMREwDwYDVQQDDAh3aGF0ZXZlcjAeFw0yMjA3MjkyMTE5Mzha\nFw0yMzA3MjkyMTE5MzhaMBUxEzARBgNVBAMMCmJhbmFuYS5jb20wggEiMA0GCSqG\nSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDVpcfcBOnFuyZG+A2WQzmaBI5NXgwTCfvE\neKciqRQXhzJdUkEg7eqwFrK3y9yjhoiB6q0WNAeR+nOdS/Cw7layRtGz5skOq7Aa\nN4FZHg0or30i7Rrx7afJcGJyLpxfK/OfLmJm5QEdLXV0DZp0L5vuhhEb1EUOrMaY\nGe4iwqTyg6D7fuBili9dBVn9IvNhYMVgtiqkWVLTW4ChE0LgES4oO3rQZgp4dtM5\nsp6KwHGO766UzwGnkKRizaqmLylfVusllWNPFfp6gEaxa45N70oqGUrvGSVHWeHf\nfvkhpWx+wOnu+2A5F/Yv3UNz2v4g7Vjt7V0tjL4KMV9YklpRjTh3AgMBAAEwDQYJ\nKoZIhvcNAQELBQADggEBAChjRzuba8zjQ7NYBVas89Oy7u++MlS8xWxh++yiUsV6\nWMk3ZemsPtXc1YmXorIQohtxLxzUPm2JhyzFzU/sOLmJQ1E/l+gtZHyRCwsb20fX\nmphuJsMVd7qv/GwEk9PBsk2uDqg4/Wix0Rx5lf95juJP7CPXQJl5FQauf3+LSz0y\nwF/j+4GqvrwsWr9hKOLmPdkyKkR6bHKtzzsxL9PM8GnElk2OpaPMMnzbL/vt2IAt\nxK01ZzPxCQCzVwHo5IJO5NR/fIyFbEPhxzG17QsRDOBR9fl9cOIvDeSO04vyZ+nz\n+kA2c3fNrZFAtpIlOOmFh8Q12rVL4sAjI5mVWnNEgvI=\n-----END CERTIFICATE-----\n", # noqa: E501 - } - ] - }, - { - "certificates": [ - { - "ca": "-----BEGIN CERTIFICATE-----\\nMIIDJTCCAg2gAwIBAgIUMsSK+4FGCjW6sL/EXMSxColmKw8wDQYJKoZIhvcNAQEL\\nBQAwIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdoYXRldmVyMB4XDTIyMDcyOTIx\\nMTgyN1oXDTIzMDcyOTIxMTgyN1owIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdo\\nYXRldmVyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA55N9DkgFWbJ/\\naqcdQhso7n1kFvt6j/fL1tJBvRubkiFMQJnZFtekfalN6FfRtA3jq+nx8o49e+7t\\nLCKT0xQ+wufXfOnxv6/if6HMhHTiCNPOCeztUgQ2+dfNwRhYYgB1P93wkUVjwudK\\n13qHTTZ6NtEF6EzOqhOCe6zxq6wrr422+ZqCvcggeQ5tW9xSd/8O1vNID/0MTKpy\\nET3drDtBfHmiUEIBR3T3tcy6QsIe4Rz/2sDinAcM3j7sG8uY6drh8jY3PWar9til\\nv2l4qDYSU8Qm5856AB1FVZRLRJkLxZYZNgreShAIYgEd0mcyI2EO/UvKxsIcxsXc\\nd45GhGpKkwIDAQABo1cwVTAfBgNVHQ4EGAQWBBRXBrXKh3p/aFdQjUcT/UcvICBL\\nODAhBgNVHSMEGjAYgBYEFFcGtcqHen9oV1CNRxP9Ry8gIEs4MA8GA1UdEwEB/wQF\\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAGmCEvcoFUrT9e133SHkgF/ZAgzeIziO\\nBjfAdU4fvAVTVfzaPm0yBnGqzcHyacCzbZjKQpaKVgc5e6IaqAQtf6cZJSCiJGhS\\nJYeosWrj3dahLOUAMrXRr8G/Ybcacoqc+osKaRa2p71cC3V6u2VvcHRV7HDFGJU7\\noijbdB+WhqET6Txe67rxZCJG9Ez3EOejBJBl2PJPpy7m1Ml4RR+E8YHNzB0lcBzc\\nEoiJKlDfKSO14E2CPDonnUoWBJWjEvJys3tbvKzsRj2fnLilytPFU0gH3cEjCopi\\nzFoWRdaRuNHYCqlBmso1JFDl8h4fMmglxGNKnKRar0WeGyxb4xXBGpI=\\n-----END CERTIFICATE-----\\n", # noqa: E501 - "chain": [ - "-----BEGIN CERTIFICATE-----\\nMIIDJTCCAg2gAwIBAgIUMsSK+4FGCjW6sL/EXMSxColmKw8wDQYJKoZIhvcNAQEL\\nBQAwIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdoYXRldmVyMB4XDTIyMDcyOTIx\\nMTgyN1oXDTIzMDcyOTIxMTgyN1owIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdo\\nYXRldmVyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA55N9DkgFWbJ/\\naqcdQhso7n1kFvt6j/fL1tJBvRubkiFMQJnZFtekfalN6FfRtA3jq+nx8o49e+7t\\nLCKT0xQ+wufXfOnxv6/if6HMhHTiCNPOCeztUgQ2+dfNwRhYYgB1P93wkUVjwudK\\n13qHTTZ6NtEF6EzOqhOCe6zxq6wrr422+ZqCvcggeQ5tW9xSd/8O1vNID/0MTKpy\\nET3drDtBfHmiUEIBR3T3tcy6QsIe4Rz/2sDinAcM3j7sG8uY6drh8jY3PWar9til\\nv2l4qDYSU8Qm5856AB1FVZRLRJkLxZYZNgreShAIYgEd0mcyI2EO/UvKxsIcxsXc\\nd45GhGpKkwIDAQABo1cwVTAfBgNVHQ4EGAQWBBRXBrXKh3p/aFdQjUcT/UcvICBL\\nODAhBgNVHSMEGjAYgBYEFFcGtcqHen9oV1CNRxP9Ry8gIEs4MA8GA1UdEwEB/wQF\\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAGmCEvcoFUrT9e133SHkgF/ZAgzeIziO\\nBjfAdU4fvAVTVfzaPm0yBnGqzcHyacCzbZjKQpaKVgc5e6IaqAQtf6cZJSCiJGhS\\nJYeosWrj3dahLOUAMrXRr8G/Ybcacoqc+osKaRa2p71cC3V6u2VvcHRV7HDFGJU7\\noijbdB+WhqET6Txe67rxZCJG9Ez3EOejBJBl2PJPpy7m1Ml4RR+E8YHNzB0lcBzc\\nEoiJKlDfKSO14E2CPDonnUoWBJWjEvJys3tbvKzsRj2fnLilytPFU0gH3cEjCopi\\nzFoWRdaRuNHYCqlBmso1JFDl8h4fMmglxGNKnKRar0WeGyxb4xXBGpI=\\n-----END CERTIFICATE-----\\n" # noqa: E501, W505 - ], - "certificate_signing_request": "-----BEGIN CERTIFICATE REQUEST-----\nMIICWjCCAUICAQAwFTETMBEGA1UEAwwKYmFuYW5hLmNvbTCCASIwDQYJKoZIhvcN\nAQEBBQADggEPADCCAQoCggEBANWlx9wE6cW7Jkb4DZZDOZoEjk1eDBMJ+8R4pyKp\nFBeHMl1SQSDt6rAWsrfL3KOGiIHqrRY0B5H6c51L8LDuVrJG0bPmyQ6rsBo3gVke\nDSivfSLtGvHtp8lwYnIunF8r858uYmblAR0tdXQNmnQvm+6GERvURQ6sxpgZ7iLC\npPKDoPt+4GKWL10FWf0i82FgxWC2KqRZUtNbgKETQuARLig7etBmCnh20zmynorA\ncY7vrpTPAaeQpGLNqqYvKV9W6yWVY08V+nqARrFrjk3vSioZSu8ZJUdZ4d9++SGl\nbH7A6e77YDkX9i/dQ3Pa/iDtWO3tXS2MvgoxX1iSWlGNOHcCAwEAAaAAMA0GCSqG\nSIb3DQEBCwUAA4IBAQCW1fKcHessy/ZhnIwAtSLznZeZNH8LTVOzkhVd4HA7EJW+\nKVLBx8DnN7L3V2/uPJfHiOg4Rx7fi7LkJPegl3SCqJZ0N5bQS/KvDTCyLG+9E8Y+\n7wqCmWiXaH1devimXZvazilu4IC2dSks2D8DPWHgsOdVks9bme8J3KjdNMQudegc\newWZZ1Dtbd+Rn7cpKU3jURMwm4fRwGxbJ7iT5fkLlPBlyM/yFEik4SmQxFYrZCQg\n0f3v4kBefTh5yclPy5tEH+8G0LMsbbo3dJ5mPKpAShi0QEKDLd7eR1R/712lYTK4\ndi4XaEfqERgy68O4rvb4PGlJeRGS7AmL7Ss8wfAq\n-----END CERTIFICATE REQUEST-----\n", # noqa: E501 - "certificate": "-----BEGIN CERTIFICATE-----\nMIICvDCCAaQCFFPAOD7utDTsgFrm0vS4We18OcnKMA0GCSqGSIb3DQEBCwUAMCAx\nCzAJBgNVBAYTAlVTMREwDwYDVQQDDAh3aGF0ZXZlcjAeFw0yMjA3MjkyMTE5Mzha\nFw0yMzA3MjkyMTE5MzhaMBUxEzARBgNVBAMMCmJhbmFuYS5jb20wggEiMA0GCSqG\nSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDVpcfcBOnFuyZG+A2WQzmaBI5NXgwTCfvE\neKciqRQXhzJdUkEg7eqwFrK3y9yjhoiB6q0WNAeR+nOdS/Cw7layRtGz5skOq7Aa\nN4FZHg0or30i7Rrx7afJcGJyLpxfK/OfLmJm5QEdLXV0DZp0L5vuhhEb1EUOrMaY\nGe4iwqTyg6D7fuBili9dBVn9IvNhYMVgtiqkWVLTW4ChE0LgES4oO3rQZgp4dtM5\nsp6KwHGO766UzwGnkKRizaqmLylfVusllWNPFfp6gEaxa45N70oqGUrvGSVHWeHf\nfvkhpWx+wOnu+2A5F/Yv3UNz2v4g7Vjt7V0tjL4KMV9YklpRjTh3AgMBAAEwDQYJ\nKoZIhvcNAQELBQADggEBAChjRzuba8zjQ7NYBVas89Oy7u++MlS8xWxh++yiUsV6\nWMk3ZemsPtXc1YmXorIQohtxLxzUPm2JhyzFzU/sOLmJQ1E/l+gtZHyRCwsb20fX\nmphuJsMVd7qv/GwEk9PBsk2uDqg4/Wix0Rx5lf95juJP7CPXQJl5FQauf3+LSz0y\nwF/j+4GqvrwsWr9hKOLmPdkyKkR6bHKtzzsxL9PM8GnElk2OpaPMMnzbL/vt2IAt\nxK01ZzPxCQCzVwHo5IJO5NR/fIyFbEPhxzG17QsRDOBR9fl9cOIvDeSO04vyZ+nz\n+kA2c3fNrZFAtpIlOOmFh8Q12rVL4sAjI5mVWnNEgvI=\n-----END CERTIFICATE-----\n", # noqa: E501 - "revoked": True, - } - ] - }, - ], - "properties": { - "certificates": { - "$id": "#/properties/certificates", - "type": "array", - "items": { - "$id": "#/properties/certificates/items", - "type": "object", - "required": ["certificate_signing_request", "certificate", "ca", "chain"], - "properties": { - "certificate_signing_request": { - "$id": "#/properties/certificates/items/certificate_signing_request", - "type": "string", - }, - "certificate": { - "$id": "#/properties/certificates/items/certificate", - "type": "string", - }, - "ca": {"$id": "#/properties/certificates/items/ca", "type": "string"}, - "chain": { - "$id": "#/properties/certificates/items/chain", - "type": "array", - "items": { - "type": "string", - "$id": "#/properties/certificates/items/chain/items", - }, - }, - "revoked": { - "$id": "#/properties/certificates/items/revoked", - "type": "boolean", - }, - }, - "additionalProperties": True, - }, - } - }, - "required": ["certificates"], - "additionalProperties": True, -} - - -logger = logging.getLogger(__name__) - - -class CertificateAvailableEvent(EventBase): - """Charm Event triggered when a TLS certificate is available.""" - - def __init__( - self, - handle: Handle, - certificate: str, - certificate_signing_request: str, - ca: str, - chain: List[str], - ): - super().__init__(handle) - self.certificate = certificate - self.certificate_signing_request = certificate_signing_request - self.ca = ca - self.chain = chain - - def snapshot(self) -> dict: - """Return snapshot.""" - return { - "certificate": self.certificate, - "certificate_signing_request": self.certificate_signing_request, - "ca": self.ca, - "chain": self.chain, - } - - def restore(self, snapshot: dict): - """Restore snapshot.""" - self.certificate = snapshot["certificate"] - self.certificate_signing_request = snapshot["certificate_signing_request"] - self.ca = snapshot["ca"] - self.chain = snapshot["chain"] - - -class CertificateExpiringEvent(EventBase): - """Charm Event triggered when a TLS certificate is almost expired.""" - - def __init__(self, handle: Handle, certificate: str, expiry: str): - """CertificateExpiringEvent. - - Args: - handle (Handle): Juju framework handle - certificate (str): TLS Certificate - expiry (str): Datetime string representing the time at which the certificate - won't be valid anymore. - """ - super().__init__(handle) - self.certificate = certificate - self.expiry = expiry - - def snapshot(self) -> dict: - """Return snapshot.""" - return {"certificate": self.certificate, "expiry": self.expiry} - - def restore(self, snapshot: dict): - """Restore snapshot.""" - self.certificate = snapshot["certificate"] - self.expiry = snapshot["expiry"] - - -class CertificateInvalidatedEvent(EventBase): - """Charm Event triggered when a TLS certificate is invalidated.""" - - def __init__( - self, - handle: Handle, - reason: Literal["expired", "revoked"], - certificate: str, - certificate_signing_request: str, - ca: str, - chain: List[str], - ): - super().__init__(handle) - self.reason = reason - self.certificate_signing_request = certificate_signing_request - self.certificate = certificate - self.ca = ca - self.chain = chain - - def snapshot(self) -> dict: - """Return snapshot.""" - return { - "reason": self.reason, - "certificate_signing_request": self.certificate_signing_request, - "certificate": self.certificate, - "ca": self.ca, - "chain": self.chain, - } - - def restore(self, snapshot: dict): - """Restore snapshot.""" - self.reason = snapshot["reason"] - self.certificate_signing_request = snapshot["certificate_signing_request"] - self.certificate = snapshot["certificate"] - self.ca = snapshot["ca"] - self.chain = snapshot["chain"] - - -class AllCertificatesInvalidatedEvent(EventBase): - """Charm Event triggered when all TLS certificates are invalidated.""" - - def __init__(self, handle: Handle): - super().__init__(handle) - - def snapshot(self) -> dict: - """Return snapshot.""" - return {} - - def restore(self, snapshot: dict): - """Restore snapshot.""" - pass - - -class CertificateCreationRequestEvent(EventBase): - """Charm Event triggered when a TLS certificate is required.""" - - def __init__( - self, - handle: Handle, - certificate_signing_request: str, - relation_id: int, - is_ca: bool = False, - ): - super().__init__(handle) - self.certificate_signing_request = certificate_signing_request - self.relation_id = relation_id - self.is_ca = is_ca - - def snapshot(self) -> dict: - """Return snapshot.""" - return { - "certificate_signing_request": self.certificate_signing_request, - "relation_id": self.relation_id, - "is_ca": self.is_ca, - } - - def restore(self, snapshot: dict): - """Restore snapshot.""" - self.certificate_signing_request = snapshot["certificate_signing_request"] - self.relation_id = snapshot["relation_id"] - self.is_ca = snapshot["is_ca"] - - -class CertificateRevocationRequestEvent(EventBase): - """Charm Event triggered when a TLS certificate needs to be revoked.""" - - def __init__( - self, - handle: Handle, - certificate: str, - certificate_signing_request: str, - ca: str, - chain: str, - ): - super().__init__(handle) - self.certificate = certificate - self.certificate_signing_request = certificate_signing_request - self.ca = ca - self.chain = chain - - def snapshot(self) -> dict: - """Return snapshot.""" - return { - "certificate": self.certificate, - "certificate_signing_request": self.certificate_signing_request, - "ca": self.ca, - "chain": self.chain, - } - - def restore(self, snapshot: dict): - """Restore snapshot.""" - self.certificate = snapshot["certificate"] - self.certificate_signing_request = snapshot["certificate_signing_request"] - self.ca = snapshot["ca"] - self.chain = snapshot["chain"] - - -def _load_relation_data(relation_data_content: RelationDataContent) -> dict: - """Load relation data from the relation data bag. - - Json loads all data. - - Args: - relation_data_content: Relation data from the databag - - Returns: - dict: Relation data in dict format. - """ - certificate_data = {} - try: - for key in relation_data_content: - try: - certificate_data[key] = json.loads(relation_data_content[key]) - except (json.decoder.JSONDecodeError, TypeError): - certificate_data[key] = relation_data_content[key] - except ModelError: - pass - return certificate_data - - -def _get_closest_future_time( - expiry_notification_time: datetime, expiry_time: datetime -) -> datetime: - """Return expiry_notification_time if not in the past, otherwise return expiry_time. - - Args: - expiry_notification_time (datetime): Notification time of impending expiration - expiry_time (datetime): Expiration time - - Returns: - datetime: expiry_notification_time if not in the past, expiry_time otherwise - """ - return ( - expiry_notification_time - if datetime.now(timezone.utc) < expiry_notification_time - else expiry_time - ) - - -def _get_certificate_expiry_time(certificate: str) -> Optional[datetime]: - """Extract expiry time from a certificate string. - - Args: - certificate (str): x509 certificate as a string - - Returns: - Optional[datetime]: Expiry datetime or None - """ - try: - certificate_object = x509.load_pem_x509_certificate(data=certificate.encode()) - return certificate_object.not_valid_after_utc - except ValueError: - logger.warning("Could not load certificate.") - return None - - -def generate_ca( - private_key: bytes, - subject: str, - private_key_password: Optional[bytes] = None, - validity: int = 365, - country: str = "US", -) -> bytes: - """Generate a CA Certificate. - - Args: - private_key (bytes): Private key - subject (str): Common Name that can be an IP or a Full Qualified Domain Name (FQDN). - private_key_password (bytes): Private key password - validity (int): Certificate validity time (in days) - country (str): Certificate Issuing country - - Returns: - bytes: CA Certificate. - """ - private_key_object = serialization.load_pem_private_key( - private_key, password=private_key_password - ) - subject_name = x509.Name( - [ - x509.NameAttribute(x509.NameOID.COUNTRY_NAME, country), - x509.NameAttribute(x509.NameOID.COMMON_NAME, subject), - ] - ) - subject_identifier_object = x509.SubjectKeyIdentifier.from_public_key( - private_key_object.public_key() # type: ignore[arg-type] - ) - subject_identifier = key_identifier = subject_identifier_object.public_bytes() - key_usage = x509.KeyUsage( - digital_signature=True, - key_encipherment=True, - key_cert_sign=True, - key_agreement=False, - content_commitment=False, - data_encipherment=False, - crl_sign=False, - encipher_only=False, - decipher_only=False, - ) - cert = ( - x509.CertificateBuilder() - .subject_name(subject_name) - .issuer_name(subject_name) - .public_key(private_key_object.public_key()) # type: ignore[arg-type] - .serial_number(x509.random_serial_number()) - .not_valid_before(datetime.now(timezone.utc)) - .not_valid_after(datetime.now(timezone.utc) + timedelta(days=validity)) - .add_extension(x509.SubjectKeyIdentifier(digest=subject_identifier), critical=False) - .add_extension( - x509.AuthorityKeyIdentifier( - key_identifier=key_identifier, - authority_cert_issuer=None, - authority_cert_serial_number=None, - ), - critical=False, - ) - .add_extension(key_usage, critical=True) - .add_extension( - x509.BasicConstraints(ca=True, path_length=None), - critical=True, - ) - .sign(private_key_object, hashes.SHA256()) # type: ignore[arg-type] - ) - return cert.public_bytes(serialization.Encoding.PEM) - - -def get_certificate_extensions( - authority_key_identifier: bytes, - csr: x509.CertificateSigningRequest, - alt_names: Optional[List[str]], - is_ca: bool, -) -> List[x509.Extension]: - """Generate a list of certificate extensions from a CSR and other known information. - - Args: - authority_key_identifier (bytes): Authority key identifier - csr (x509.CertificateSigningRequest): CSR - alt_names (list): List of alt names to put on cert - prefer putting SANs in CSR - is_ca (bool): Whether the certificate is a CA certificate - - Returns: - List[x509.Extension]: List of extensions - """ - cert_extensions_list: List[x509.Extension] = [ - x509.Extension( - oid=ExtensionOID.AUTHORITY_KEY_IDENTIFIER, - value=x509.AuthorityKeyIdentifier( - key_identifier=authority_key_identifier, - authority_cert_issuer=None, - authority_cert_serial_number=None, - ), - critical=False, - ), - x509.Extension( - oid=ExtensionOID.SUBJECT_KEY_IDENTIFIER, - value=x509.SubjectKeyIdentifier.from_public_key(csr.public_key()), - critical=False, - ), - x509.Extension( - oid=ExtensionOID.BASIC_CONSTRAINTS, - critical=True, - value=x509.BasicConstraints(ca=is_ca, path_length=None), - ), - ] - - sans: List[x509.GeneralName] = [] - san_alt_names = [x509.DNSName(name) for name in alt_names] if alt_names else [] - sans.extend(san_alt_names) - try: - loaded_san_ext = csr.extensions.get_extension_for_class(x509.SubjectAlternativeName) - sans.extend( - [x509.DNSName(name) for name in loaded_san_ext.value.get_values_for_type(x509.DNSName)] - ) - sans.extend( - [x509.IPAddress(ip) for ip in loaded_san_ext.value.get_values_for_type(x509.IPAddress)] - ) - sans.extend( - [ - x509.RegisteredID(oid) - for oid in loaded_san_ext.value.get_values_for_type(x509.RegisteredID) - ] - ) - except x509.ExtensionNotFound: - pass - - if sans: - cert_extensions_list.append( - x509.Extension( - oid=ExtensionOID.SUBJECT_ALTERNATIVE_NAME, - critical=False, - value=x509.SubjectAlternativeName(sans), - ) - ) - - if is_ca: - cert_extensions_list.append( - x509.Extension( - ExtensionOID.KEY_USAGE, - critical=True, - value=x509.KeyUsage( - digital_signature=False, - content_commitment=False, - key_encipherment=False, - data_encipherment=False, - key_agreement=False, - key_cert_sign=True, - crl_sign=True, - encipher_only=False, - decipher_only=False, - ), - ) - ) - - existing_oids = {ext.oid for ext in cert_extensions_list} - for extension in csr.extensions: - if extension.oid == ExtensionOID.SUBJECT_ALTERNATIVE_NAME: - continue - if extension.oid in existing_oids: - logger.warning("Extension %s is managed by the TLS provider, ignoring.", extension.oid) - continue - cert_extensions_list.append(extension) - - return cert_extensions_list - - -def generate_certificate( - csr: bytes, - ca: bytes, - ca_key: bytes, - ca_key_password: Optional[bytes] = None, - validity: int = 365, - alt_names: Optional[List[str]] = None, - is_ca: bool = False, -) -> bytes: - """Generate a TLS certificate based on a CSR. - - Args: - csr (bytes): CSR - ca (bytes): CA Certificate - ca_key (bytes): CA private key - ca_key_password: CA private key password - validity (int): Certificate validity (in days) - alt_names (list): List of alt names to put on cert - prefer putting SANs in CSR - is_ca (bool): Whether the certificate is a CA certificate - - Returns: - bytes: Certificate - """ - csr_object = x509.load_pem_x509_csr(csr) - subject = csr_object.subject - ca_pem = x509.load_pem_x509_certificate(ca) - issuer = ca_pem.issuer - private_key = serialization.load_pem_private_key(ca_key, password=ca_key_password) - - certificate_builder = ( - x509.CertificateBuilder() - .subject_name(subject) - .issuer_name(issuer) - .public_key(csr_object.public_key()) - .serial_number(x509.random_serial_number()) - .not_valid_before(datetime.now(timezone.utc)) - .not_valid_after(datetime.now(timezone.utc) + timedelta(days=validity)) - ) - extensions = get_certificate_extensions( - authority_key_identifier=ca_pem.extensions.get_extension_for_class( - x509.SubjectKeyIdentifier - ).value.key_identifier, - csr=csr_object, - alt_names=alt_names, - is_ca=is_ca, - ) - for extension in extensions: - try: - certificate_builder = certificate_builder.add_extension( - extval=extension.value, - critical=extension.critical, - ) - except ValueError as e: - logger.warning("Failed to add extension %s: %s", extension.oid, e) - - cert = certificate_builder.sign(private_key, hashes.SHA256()) # type: ignore[arg-type] - return cert.public_bytes(serialization.Encoding.PEM) - - -def generate_pfx_package( - certificate: bytes, - private_key: bytes, - package_password: str, - private_key_password: Optional[bytes] = None, -) -> bytes: - """Generate a PFX package to contain the TLS certificate and private key. - - Args: - certificate (bytes): TLS certificate - private_key (bytes): Private key - package_password (str): Password to open the PFX package - private_key_password (bytes): Private key password - - Returns: - bytes: - """ - private_key_object = serialization.load_pem_private_key( - private_key, password=private_key_password - ) - certificate_object = x509.load_pem_x509_certificate(certificate) - name = certificate_object.subject.rfc4514_string() - pfx_bytes = pkcs12.serialize_key_and_certificates( - name=name.encode(), - cert=certificate_object, - key=private_key_object, # type: ignore[arg-type] - cas=None, - encryption_algorithm=serialization.BestAvailableEncryption(package_password.encode()), - ) - return pfx_bytes - - -def generate_private_key( - password: Optional[bytes] = None, - key_size: int = 2048, - public_exponent: int = 65537, -) -> bytes: - """Generate a private key. - - Args: - password (bytes): Password for decrypting the private key - key_size (int): Key size in bytes - public_exponent: Public exponent. - - Returns: - bytes: Private Key - """ - private_key = rsa.generate_private_key( - public_exponent=public_exponent, - key_size=key_size, - ) - key_bytes = private_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.TraditionalOpenSSL, - encryption_algorithm=( - serialization.BestAvailableEncryption(password) - if password - else serialization.NoEncryption() - ), - ) - return key_bytes - - -def generate_csr( # noqa: C901 - private_key: bytes, - subject: str, - add_unique_id_to_subject_name: bool = True, - organization: Optional[str] = None, - email_address: Optional[str] = None, - country_name: Optional[str] = None, - private_key_password: Optional[bytes] = None, - sans: Optional[List[str]] = None, - sans_oid: Optional[List[str]] = None, - sans_ip: Optional[List[str]] = None, - sans_dns: Optional[List[str]] = None, - additional_critical_extensions: Optional[List] = None, -) -> bytes: - """Generate a CSR using private key and subject. - - Args: - private_key (bytes): Private key - subject (str): CSR Common Name that can be an IP or a Full Qualified Domain Name (FQDN). - add_unique_id_to_subject_name (bool): Whether a unique ID must be added to the CSR's - subject name. Always leave to "True" when the CSR is used to request certificates - using the tls-certificates relation. - organization (str): Name of organization. - email_address (str): Email address. - country_name (str): Country Name. - private_key_password (bytes): Private key password - sans (list): Use sans_dns - this will be deprecated in a future release - List of DNS subject alternative names (keeping it for now for backward compatibility) - sans_oid (list): List of registered ID SANs - sans_dns (list): List of DNS subject alternative names (similar to the arg: sans) - sans_ip (list): List of IP subject alternative names - additional_critical_extensions (list): List of critical additional extension objects. - Object must be a x509 ExtensionType. - - Returns: - bytes: CSR - """ - signing_key = serialization.load_pem_private_key(private_key, password=private_key_password) - subject_name = [x509.NameAttribute(x509.NameOID.COMMON_NAME, subject)] - if add_unique_id_to_subject_name: - unique_identifier = uuid.uuid4() - subject_name.append( - x509.NameAttribute(x509.NameOID.X500_UNIQUE_IDENTIFIER, str(unique_identifier)) - ) - if organization: - subject_name.append(x509.NameAttribute(x509.NameOID.ORGANIZATION_NAME, organization)) - if email_address: - subject_name.append(x509.NameAttribute(x509.NameOID.EMAIL_ADDRESS, email_address)) - if country_name: - subject_name.append(x509.NameAttribute(x509.NameOID.COUNTRY_NAME, country_name)) - csr = x509.CertificateSigningRequestBuilder(subject_name=x509.Name(subject_name)) - - _sans: List[x509.GeneralName] = [] - if sans_oid: - _sans.extend([x509.RegisteredID(x509.ObjectIdentifier(san)) for san in sans_oid]) - if sans_ip: - _sans.extend([x509.IPAddress(IPv4Address(san)) for san in sans_ip]) - if sans: - _sans.extend([x509.DNSName(san) for san in sans]) - if sans_dns: - _sans.extend([x509.DNSName(san) for san in sans_dns]) - if _sans: - csr = csr.add_extension(x509.SubjectAlternativeName(set(_sans)), critical=False) - - if additional_critical_extensions: - for extension in additional_critical_extensions: - csr = csr.add_extension(extension, critical=True) - - signed_certificate = csr.sign(signing_key, hashes.SHA256()) # type: ignore[arg-type] - return signed_certificate.public_bytes(serialization.Encoding.PEM) - - -def csr_matches_certificate(csr: str, cert: str) -> bool: - """Check if a CSR matches a certificate. - - Args: - csr (str): Certificate Signing Request as a string - cert (str): Certificate as a string - Returns: - bool: True/False depending on whether the CSR matches the certificate. - """ - try: - csr_object = x509.load_pem_x509_csr(csr.encode("utf-8")) - cert_object = x509.load_pem_x509_certificate(cert.encode("utf-8")) - - if csr_object.public_key().public_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PublicFormat.SubjectPublicKeyInfo, - ) != cert_object.public_key().public_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PublicFormat.SubjectPublicKeyInfo, - ): - return False - if ( - csr_object.public_key().public_numbers().n # type: ignore[union-attr] - != cert_object.public_key().public_numbers().n # type: ignore[union-attr] - ): - return False - except ValueError: - logger.warning("Could not load certificate or CSR.") - return False - return True - - -class CertificatesProviderCharmEvents(CharmEvents): - """List of events that the TLS Certificates provider charm can leverage.""" - - certificate_creation_request = EventSource(CertificateCreationRequestEvent) - certificate_revocation_request = EventSource(CertificateRevocationRequestEvent) - - -class CertificatesRequirerCharmEvents(CharmEvents): - """List of events that the TLS Certificates requirer charm can leverage.""" - - certificate_available = EventSource(CertificateAvailableEvent) - certificate_expiring = EventSource(CertificateExpiringEvent) - certificate_invalidated = EventSource(CertificateInvalidatedEvent) - all_certificates_invalidated = EventSource(AllCertificatesInvalidatedEvent) - - -class TLSCertificatesProvidesV2(Object): - """TLS certificates provider class to be instantiated by TLS certificates providers.""" - - on = CertificatesProviderCharmEvents() # type: ignore[reportAssignmentType] - - def __init__(self, charm: CharmBase, relationship_name: str): - super().__init__(charm, relationship_name) - self.framework.observe( - charm.on[relationship_name].relation_changed, self._on_relation_changed - ) - self.charm = charm - self.relationship_name = relationship_name - - def _load_app_relation_data(self, relation: Relation) -> dict: - """Load relation data from the application relation data bag. - - Json loads all data. - - Args: - relation: Relation data from the application databag - - Returns: - dict: Relation data in dict format. - """ - # If unit is not leader, it does not try to reach relation data. - if not self.model.unit.is_leader(): - return {} - return _load_relation_data(relation.data[self.charm.app]) - - def _add_certificate( - self, - relation_id: int, - certificate: str, - certificate_signing_request: str, - ca: str, - chain: List[str], - ) -> None: - """Add certificate to relation data. - - Args: - relation_id (int): Relation id - certificate (str): Certificate - certificate_signing_request (str): Certificate Signing Request - ca (str): CA Certificate - chain (list): CA Chain - - Returns: - None - """ - relation = self.model.get_relation( - relation_name=self.relationship_name, relation_id=relation_id - ) - if not relation: - raise RuntimeError( - f"Relation {self.relationship_name} does not exist - " - f"The certificate request can't be completed" - ) - new_certificate = { - "certificate": certificate, - "certificate_signing_request": certificate_signing_request, - "ca": ca, - "chain": chain, - } - provider_relation_data = self._load_app_relation_data(relation) - provider_certificates = provider_relation_data.get("certificates", []) - certificates = copy.deepcopy(provider_certificates) - if new_certificate in certificates: - logger.info("Certificate already in relation data - Doing nothing") - return - certificates.append(new_certificate) - relation.data[self.model.app]["certificates"] = json.dumps(certificates) - - def _remove_certificate( - self, - relation_id: int, - certificate: Optional[str] = None, - certificate_signing_request: Optional[str] = None, - ) -> None: - """Remove certificate from a given relation based on user provided certificate or csr. - - Args: - relation_id (int): Relation id - certificate (str): Certificate (optional) - certificate_signing_request: Certificate signing request (optional) - - Returns: - None - """ - relation = self.model.get_relation( - relation_name=self.relationship_name, - relation_id=relation_id, - ) - if not relation: - raise RuntimeError( - f"Relation {self.relationship_name} with relation id {relation_id} does not exist" - ) - provider_relation_data = self._load_app_relation_data(relation) - provider_certificates = provider_relation_data.get("certificates", []) - certificates = copy.deepcopy(provider_certificates) - for certificate_dict in certificates: - if certificate and certificate_dict["certificate"] == certificate: - certificates.remove(certificate_dict) - if ( - certificate_signing_request - and certificate_dict["certificate_signing_request"] == certificate_signing_request - ): - certificates.remove(certificate_dict) - relation.data[self.model.app]["certificates"] = json.dumps(certificates) - - @staticmethod - def _relation_data_is_valid(certificates_data: dict) -> bool: - """Use JSON schema validator to validate relation data content. - - Args: - certificates_data (dict): Certificate data dictionary as retrieved from relation data. - - Returns: - bool: True/False depending on whether the relation data follows the json schema. - """ - try: - validate(instance=certificates_data, schema=REQUIRER_JSON_SCHEMA) - return True - except exceptions.ValidationError: - return False - - def revoke_all_certificates(self) -> None: - """Revoke all certificates of this provider. - - This method is meant to be used when the Root CA has changed. - """ - for relation in self.model.relations[self.relationship_name]: - provider_relation_data = self._load_app_relation_data(relation) - provider_certificates = copy.deepcopy(provider_relation_data.get("certificates", [])) - for certificate in provider_certificates: - certificate["revoked"] = True - relation.data[self.model.app]["certificates"] = json.dumps(provider_certificates) - - def set_relation_certificate( - self, - certificate: str, - certificate_signing_request: str, - ca: str, - chain: List[str], - relation_id: int, - ) -> None: - """Add certificates to relation data. - - Args: - certificate (str): Certificate - certificate_signing_request (str): Certificate signing request - ca (str): CA Certificate - chain (list): CA Chain - relation_id (int): Juju relation ID - - Returns: - None - """ - if not self.model.unit.is_leader(): - return - certificates_relation = self.model.get_relation( - relation_name=self.relationship_name, relation_id=relation_id - ) - if not certificates_relation: - raise RuntimeError(f"Relation {self.relationship_name} does not exist") - self._remove_certificate( - certificate_signing_request=certificate_signing_request.strip(), - relation_id=relation_id, - ) - self._add_certificate( - relation_id=relation_id, - certificate=certificate.strip(), - certificate_signing_request=certificate_signing_request.strip(), - ca=ca.strip(), - chain=[cert.strip() for cert in chain], - ) - - def remove_certificate(self, certificate: str) -> None: - """Remove a given certificate from relation data. - - Args: - certificate (str): TLS Certificate - - Returns: - None - """ - certificates_relation = self.model.relations[self.relationship_name] - if not certificates_relation: - raise RuntimeError(f"Relation {self.relationship_name} does not exist") - for certificate_relation in certificates_relation: - self._remove_certificate(certificate=certificate, relation_id=certificate_relation.id) - - def get_issued_certificates( - self, relation_id: Optional[int] = None - ) -> Dict[str, List[Dict[str, str]]]: - """Return a dictionary of issued certificates. - - It returns certificates from all relations if relation_id is not specified. - Certificates are returned per application name and CSR. - - Returns: - dict: Certificates per application name. - """ - certificates: Dict[str, List[Dict[str, str]]] = {} - relations = ( - [ - relation - for relation in self.model.relations[self.relationship_name] - if relation.id == relation_id - ] - if relation_id is not None - else self.model.relations.get(self.relationship_name, []) - ) - for relation in relations: - provider_relation_data = self._load_app_relation_data(relation) - provider_certificates = provider_relation_data.get("certificates", []) - - certificates[relation.app.name] = [] # type: ignore[union-attr] - for certificate in provider_certificates: - if not certificate.get("revoked", False): - certificates[relation.app.name].append( # type: ignore[union-attr] - { - "csr": certificate["certificate_signing_request"], - "certificate": certificate["certificate"], - } - ) - - return certificates - - def _on_relation_changed(self, event: RelationChangedEvent) -> None: - """Handle relation changed event. - - Looks at the relation data and either emits: - - certificate request event: If the unit relation data contains a CSR for which - a certificate does not exist in the provider relation data. - - certificate revocation event: If the provider relation data contains a CSR for which - a csr does not exist in the requirer relation data. - - Args: - event: Juju event - - Returns: - None - """ - if event.unit is None: - logger.error("Relation_changed event does not have a unit.") - return - if not self.model.unit.is_leader(): - return - requirer_relation_data = _load_relation_data(event.relation.data[event.unit]) - provider_relation_data = self._load_app_relation_data(event.relation) - if not self._relation_data_is_valid(requirer_relation_data): - logger.debug("Relation data did not pass JSON Schema validation") - return - provider_certificates = provider_relation_data.get("certificates", []) - requirer_csrs = requirer_relation_data.get("certificate_signing_requests", []) - provider_csrs = [ - certificate_creation_request["certificate_signing_request"] - for certificate_creation_request in provider_certificates - ] - requirer_unit_certificate_requests = [ - { - "csr": certificate_creation_request["certificate_signing_request"], - "is_ca": certificate_creation_request.get("ca", False), - } - for certificate_creation_request in requirer_csrs - ] - for certificate_request in requirer_unit_certificate_requests: - if certificate_request["csr"] not in provider_csrs: - self.on.certificate_creation_request.emit( - certificate_signing_request=certificate_request["csr"], - relation_id=event.relation.id, - is_ca=certificate_request["is_ca"], - ) - self._revoke_certificates_for_which_no_csr_exists(relation_id=event.relation.id) - - def _revoke_certificates_for_which_no_csr_exists(self, relation_id: int) -> None: - """Revoke certificates for which no unit has a CSR. - - Goes through all generated certificates and compare against the list of CSRs for all units - of a given relationship. - - Args: - relation_id (int): Relation id - - Returns: - None - """ - certificates_relation = self.model.get_relation( - relation_name=self.relationship_name, relation_id=relation_id - ) - if not certificates_relation: - raise RuntimeError(f"Relation {self.relationship_name} does not exist") - provider_relation_data = self._load_app_relation_data(certificates_relation) - list_of_csrs: List[str] = [] - for unit in certificates_relation.units: - requirer_relation_data = _load_relation_data(certificates_relation.data[unit]) - requirer_csrs = requirer_relation_data.get("certificate_signing_requests", []) - list_of_csrs.extend(csr["certificate_signing_request"] for csr in requirer_csrs) - provider_certificates = provider_relation_data.get("certificates", []) - for certificate in provider_certificates: - if certificate["certificate_signing_request"] not in list_of_csrs: - self.on.certificate_revocation_request.emit( - certificate=certificate["certificate"], - certificate_signing_request=certificate["certificate_signing_request"], - ca=certificate["ca"], - chain=certificate["chain"], - ) - self.remove_certificate(certificate=certificate["certificate"]) - - def get_outstanding_certificate_requests( - self, relation_id: Optional[int] = None - ) -> List[Dict[str, Union[int, str, List[Dict[str, str]]]]]: - """Return CSR's for which no certificate has been issued. - - Example return: [ - { - "relation_id": 0, - "application_name": "tls-certificates-requirer", - "unit_name": "tls-certificates-requirer/0", - "unit_csrs": [ - { - "certificate_signing_request": "-----BEGIN CERTIFICATE REQUEST-----...", - "is_ca": false - } - ] - } - ] - - Args: - relation_id (int): Relation id - - Returns: - list: List of dictionaries that contain the unit's csrs - that don't have a certificate issued. - """ - all_unit_csr_mappings = copy.deepcopy(self.get_requirer_csrs(relation_id=relation_id)) - filtered_all_unit_csr_mappings: List[Dict[str, Union[int, str, List[Dict[str, str]]]]] = [] - for unit_csr_mapping in all_unit_csr_mappings: - csrs_without_certs = [] - for csr in unit_csr_mapping["unit_csrs"]: # type: ignore[union-attr] - if not self.certificate_issued_for_csr( - app_name=unit_csr_mapping["application_name"], # type: ignore[arg-type] - csr=csr["certificate_signing_request"], # type: ignore[index] - relation_id=relation_id, - ): - csrs_without_certs.append(csr) - if csrs_without_certs: - unit_csr_mapping["unit_csrs"] = csrs_without_certs # type: ignore[assignment] - filtered_all_unit_csr_mappings.append(unit_csr_mapping) - return filtered_all_unit_csr_mappings - - def get_requirer_csrs( - self, relation_id: Optional[int] = None - ) -> List[Dict[str, Union[int, str, List[Dict[str, str]]]]]: - """Return a list of requirers' CSRs grouped by unit. - - It returns CSRs from all relations if relation_id is not specified. - CSRs are returned per relation id, application name and unit name. - - Returns: - list: List of dictionaries that contain the unit's csrs - with the following information - relation_id, application_name and unit_name. - """ - unit_csr_mappings: List[Dict[str, Union[int, str, List[Dict[str, str]]]]] = [] - - relations = ( - [ - relation - for relation in self.model.relations[self.relationship_name] - if relation.id == relation_id - ] - if relation_id is not None - else self.model.relations.get(self.relationship_name, []) - ) - - for relation in relations: - for unit in relation.units: - requirer_relation_data = _load_relation_data(relation.data[unit]) - unit_csrs_list = requirer_relation_data.get("certificate_signing_requests", []) - unit_csr_mappings.append( - { - "relation_id": relation.id, - "application_name": relation.app.name, # type: ignore[union-attr] - "unit_name": unit.name, - "unit_csrs": unit_csrs_list, - } - ) - return unit_csr_mappings - - def certificate_issued_for_csr( - self, app_name: str, csr: str, relation_id: Optional[int] - ) -> bool: - """Check whether a certificate has been issued for a given CSR. - - Args: - app_name (str): Application name that the CSR belongs to. - csr (str): Certificate Signing Request. - relation_id (Optional[int]): Relation ID - Returns: - bool: True/False depending on whether a certificate has been issued for the given CSR. - """ - issued_certificates_per_csr = self.get_issued_certificates(relation_id=relation_id)[ - app_name - ] - for issued_pair in issued_certificates_per_csr: - if "csr" in issued_pair and issued_pair["csr"] == csr: - return csr_matches_certificate(csr, issued_pair["certificate"]) - return False - - -class TLSCertificatesRequiresV2(Object): - """TLS certificates requirer class to be instantiated by TLS certificates requirers.""" - - on = CertificatesRequirerCharmEvents() # type: ignore[reportAssignmentType] - - def __init__( - self, - charm: CharmBase, - relationship_name: str, - expiry_notification_time: int = 168, - ): - """Generate/use private key and observes relation changed event. - - Args: - charm: Charm object - relationship_name: Juju relation name - expiry_notification_time (int): Time difference between now and expiry (in hours). - Used to trigger the CertificateExpiring event. Default: 7 days. - """ - super().__init__(charm, relationship_name) - self.relationship_name = relationship_name - self.charm = charm - self.expiry_notification_time = expiry_notification_time - self.framework.observe( - charm.on[relationship_name].relation_changed, self._on_relation_changed - ) - self.framework.observe( - charm.on[relationship_name].relation_broken, self._on_relation_broken - ) - if JujuVersion.from_environ().has_secrets: - self.framework.observe(charm.on.secret_expired, self._on_secret_expired) - else: - self.framework.observe(charm.on.update_status, self._on_update_status) - - @property - def _requirer_csrs(self) -> List[Dict[str, Union[bool, str]]]: - """Return list of requirer's CSRs from relation unit data. - - Example: - [ - { - "certificate_signing_request": "-----BEGIN CERTIFICATE REQUEST-----...", - "ca": false - } - ] - """ - relation = self.model.get_relation(self.relationship_name) - if not relation: - raise RuntimeError(f"Relation {self.relationship_name} does not exist") - requirer_relation_data = _load_relation_data(relation.data[self.model.unit]) - return requirer_relation_data.get("certificate_signing_requests", []) - - @property - def _provider_certificates(self) -> List[Dict[str, str]]: - """Return list of certificates from the provider's relation data.""" - relation = self.model.get_relation(self.relationship_name) - if not relation: - logger.debug("No relation: %s", self.relationship_name) - return [] - if not relation.app: - logger.debug("No remote app in relation: %s", self.relationship_name) - return [] - provider_relation_data = _load_relation_data(relation.data[relation.app]) - if not self._relation_data_is_valid(provider_relation_data): - logger.warning("Provider relation data did not pass JSON Schema validation") - return [] - return provider_relation_data.get("certificates", []) - - def _add_requirer_csr(self, csr: str, is_ca: bool) -> None: - """Add CSR to relation data. - - Args: - csr (str): Certificate Signing Request - is_ca (bool): Whether the certificate is a CA certificate - - Returns: - None - """ - relation = self.model.get_relation(self.relationship_name) - if not relation: - raise RuntimeError( - f"Relation {self.relationship_name} does not exist - " - f"The certificate request can't be completed" - ) - new_csr_dict: Dict[str, Union[bool, str]] = { - "certificate_signing_request": csr, - "ca": is_ca, - } - if new_csr_dict in self._requirer_csrs: - logger.info("CSR already in relation data - Doing nothing") - return - requirer_csrs = copy.deepcopy(self._requirer_csrs) - requirer_csrs.append(new_csr_dict) - relation.data[self.model.unit]["certificate_signing_requests"] = json.dumps(requirer_csrs) - - def _remove_requirer_csr(self, csr: str) -> None: - """Remove CSR from relation data. - - Args: - csr (str): Certificate signing request - - Returns: - None - """ - relation = self.model.get_relation(self.relationship_name) - if not relation: - raise RuntimeError( - f"Relation {self.relationship_name} does not exist - " - f"The certificate request can't be completed" - ) - requirer_csrs = copy.deepcopy(self._requirer_csrs) - if not requirer_csrs: - logger.info("No CSRs in relation data - Doing nothing") - return - for requirer_csr in requirer_csrs: - if requirer_csr["certificate_signing_request"] == csr: - requirer_csrs.remove(requirer_csr) - relation.data[self.model.unit]["certificate_signing_requests"] = json.dumps(requirer_csrs) - - def request_certificate_creation( - self, certificate_signing_request: bytes, is_ca: bool = False - ) -> None: - """Request TLS certificate to provider charm. - - Args: - certificate_signing_request (bytes): Certificate Signing Request - is_ca (bool): Whether the certificate is a CA certificate - - Returns: - None - """ - relation = self.model.get_relation(self.relationship_name) - if not relation: - raise RuntimeError( - f"Relation {self.relationship_name} does not exist - " - f"The certificate request can't be completed" - ) - self._add_requirer_csr(certificate_signing_request.decode().strip(), is_ca=is_ca) - logger.info("Certificate request sent to provider") - - def request_certificate_revocation(self, certificate_signing_request: bytes) -> None: - """Remove CSR from relation data. - - The provider of this relation is then expected to remove certificates associated to this - CSR from the relation data as well and emit a request_certificate_revocation event for the - provider charm to interpret. - - Args: - certificate_signing_request (bytes): Certificate Signing Request - - Returns: - None - """ - self._remove_requirer_csr(certificate_signing_request.decode().strip()) - logger.info("Certificate revocation sent to provider") - - def request_certificate_renewal( - self, old_certificate_signing_request: bytes, new_certificate_signing_request: bytes - ) -> None: - """Renew certificate. - - Removes old CSR from relation data and adds new one. - - Args: - old_certificate_signing_request: Old CSR - new_certificate_signing_request: New CSR - - Returns: - None - """ - try: - self.request_certificate_revocation( - certificate_signing_request=old_certificate_signing_request - ) - except RuntimeError: - logger.warning("Certificate revocation failed.") - self.request_certificate_creation( - certificate_signing_request=new_certificate_signing_request - ) - logger.info("Certificate renewal request completed.") - - def get_assigned_certificates(self) -> List[Dict[str, str]]: - """Get a list of certificates that were assigned to this unit. - - Returns: - List of certificates. For example: - [ - { - "ca": "-----BEGIN CERTIFICATE-----...", - "chain": [ - "-----BEGIN CERTIFICATE-----..." - ], - "certificate": "-----BEGIN CERTIFICATE-----...", - "certificate_signing_request": "-----BEGIN CERTIFICATE REQUEST-----...", - } - ] - """ - final_list = [] - for csr in self.get_certificate_signing_requests(fulfilled_only=True): - assert isinstance(csr["certificate_signing_request"], str) - if cert := self._find_certificate_in_relation_data(csr["certificate_signing_request"]): - final_list.append(cert) - return final_list - - def get_expiring_certificates(self) -> List[Dict[str, str]]: - """Get a list of certificates that were assigned to this unit that are expiring or expired. - - Returns: - List of certificates. For example: - [ - { - "ca": "-----BEGIN CERTIFICATE-----...", - "chain": [ - "-----BEGIN CERTIFICATE-----..." - ], - "certificate": "-----BEGIN CERTIFICATE-----...", - "certificate_signing_request": "-----BEGIN CERTIFICATE REQUEST-----...", - } - ] - """ - final_list = [] - for csr in self.get_certificate_signing_requests(fulfilled_only=True): - assert isinstance(csr["certificate_signing_request"], str) - if cert := self._find_certificate_in_relation_data(csr["certificate_signing_request"]): - expiry_time = _get_certificate_expiry_time(cert["certificate"]) - if not expiry_time: - continue - expiry_notification_time = expiry_time - timedelta( - hours=self.expiry_notification_time - ) - if datetime.now(timezone.utc) > expiry_notification_time: - final_list.append(cert) - return final_list - - def get_certificate_signing_requests( - self, - fulfilled_only: bool = False, - unfulfilled_only: bool = False, - ) -> List[Dict[str, Union[bool, str]]]: - """Get the list of CSR's that were sent to the provider. - - You can choose to get only the CSR's that have a certificate assigned or only the CSR's - that don't. - - Args: - fulfilled_only (bool): This option will discard CSRs that don't have certificates yet. - unfulfilled_only (bool): This option will discard CSRs that have certificates signed. - - Returns: - List of CSR dictionaries. For example: - [ - { - "certificate_signing_request": "-----BEGIN CERTIFICATE REQUEST-----...", - "ca": false - } - ] - """ - final_list = [] - for csr in self._requirer_csrs: - assert isinstance(csr["certificate_signing_request"], str) - cert = self._find_certificate_in_relation_data(csr["certificate_signing_request"]) - if (unfulfilled_only and cert) or (fulfilled_only and not cert): - continue - final_list.append(csr) - - return final_list - - @staticmethod - def _relation_data_is_valid(certificates_data: dict) -> bool: - """Check whether relation data is valid based on json schema. - - Args: - certificates_data: Certificate data in dict format. - - Returns: - bool: Whether relation data is valid. - """ - try: - validate(instance=certificates_data, schema=PROVIDER_JSON_SCHEMA) - return True - except exceptions.ValidationError: - return False - - def _on_relation_changed(self, event: RelationChangedEvent) -> None: - """Handle relation changed event. - - Goes through all providers certificates that match a requested CSR. - - If the provider certificate is revoked, emit a CertificateInvalidateEvent, - otherwise emit a CertificateAvailableEvent. - - When Juju secrets are available, remove the secret for revoked certificate, - or add a secret with the correct expiry time for new certificates. - - - Args: - event: Juju event - - Returns: - None - """ - requirer_csrs = [ - certificate_creation_request["certificate_signing_request"] - for certificate_creation_request in self._requirer_csrs - ] - for certificate in self._provider_certificates: - if certificate["certificate_signing_request"] in requirer_csrs: - if certificate.get("revoked", False): - if JujuVersion.from_environ().has_secrets: - with suppress(SecretNotFoundError): - secret = self.model.get_secret( - label=f"{LIBID}-{certificate['certificate_signing_request']}" - ) - secret.remove_all_revisions() - self.on.certificate_invalidated.emit( - reason="revoked", - certificate=certificate["certificate"], - certificate_signing_request=certificate["certificate_signing_request"], - ca=certificate["ca"], - chain=certificate["chain"], - ) - else: - if JujuVersion.from_environ().has_secrets: - try: - secret = self.model.get_secret( - label=f"{LIBID}-{certificate['certificate_signing_request']}" - ) - secret.set_content({"certificate": certificate["certificate"]}) - secret.set_info( - expire=self._get_next_secret_expiry_time( - certificate["certificate"] - ), - ) - except SecretNotFoundError: - secret = self.charm.unit.add_secret( - {"certificate": certificate["certificate"]}, - label=f"{LIBID}-{certificate['certificate_signing_request']}", - expire=self._get_next_secret_expiry_time( - certificate["certificate"] - ), - ) - self.on.certificate_available.emit( - certificate_signing_request=certificate["certificate_signing_request"], - certificate=certificate["certificate"], - ca=certificate["ca"], - chain=certificate["chain"], - ) - - def _get_next_secret_expiry_time(self, certificate: str) -> Optional[datetime]: - """Return the expiry time or expiry notification time. - - Extracts the expiry time from the provided certificate, calculates the - expiry notification time and return the closest of the two, that is in - the future. - - Args: - certificate: x509 certificate - - Returns: - Optional[datetime]: None if the certificate expiry time cannot be read, - next expiry time otherwise. - """ - expiry_time = _get_certificate_expiry_time(certificate) - if not expiry_time: - return None - expiry_notification_time = expiry_time - timedelta(hours=self.expiry_notification_time) - return _get_closest_future_time(expiry_notification_time, expiry_time) - - def _on_relation_broken(self, event: RelationBrokenEvent) -> None: - """Handle relation broken event. - - Emitting `all_certificates_invalidated` from `relation-broken` rather - than `relation-departed` since certs are stored in app data. - - Args: - event: Juju event - - Returns: - None - """ - self.on.all_certificates_invalidated.emit() - - def _on_secret_expired(self, event: SecretExpiredEvent) -> None: - """Handle secret expired event. - - Loads the certificate from the secret, and will emit 1 of 2 - events. - - If the certificate is not yet expired, emits CertificateExpiringEvent - and updates the expiry time of the secret to the exact expiry time on - the certificate. - - If the certificate is expired, emits CertificateInvalidedEvent and - deletes the secret. - - Args: - event (SecretExpiredEvent): Juju event - """ - if not event.secret.label or not event.secret.label.startswith(f"{LIBID}-"): - return - csr = event.secret.label[len(f"{LIBID}-") :] - certificate_dict = self._find_certificate_in_relation_data(csr) - if not certificate_dict: - # A secret expired but we did not find matching certificate. Cleaning up - event.secret.remove_all_revisions() - return - - expiry_time = _get_certificate_expiry_time(certificate_dict["certificate"]) - if not expiry_time: - # A secret expired but matching certificate is invalid. Cleaning up - event.secret.remove_all_revisions() - return - - if datetime.now(timezone.utc) < expiry_time: - logger.warning("Certificate almost expired") - self.on.certificate_expiring.emit( - certificate=certificate_dict["certificate"], - expiry=expiry_time.isoformat(), - ) - event.secret.set_info( - expire=_get_certificate_expiry_time(certificate_dict["certificate"]), - ) - else: - logger.warning("Certificate is expired") - self.on.certificate_invalidated.emit( - reason="expired", - certificate=certificate_dict["certificate"], - certificate_signing_request=certificate_dict["certificate_signing_request"], - ca=certificate_dict["ca"], - chain=certificate_dict["chain"], - ) - self.request_certificate_revocation(certificate_dict["certificate"].encode()) - event.secret.remove_all_revisions() - - def _find_certificate_in_relation_data(self, csr: str) -> Optional[Dict[str, Any]]: - """Return the certificate that match the given CSR.""" - for certificate_dict in self._provider_certificates: - if certificate_dict["certificate_signing_request"] != csr: - continue - return certificate_dict - return None - - def _on_update_status(self, event: UpdateStatusEvent) -> None: - """Handle update status event. - - Goes through each certificate in the "certificates" relation and checks their expiry date. - If they are close to expire (<7 days), emits a CertificateExpiringEvent event and if - they are expired, emits a CertificateExpiredEvent. - - Args: - event (UpdateStatusEvent): Juju event - - Returns: - None - """ - for certificate_dict in self._provider_certificates: - expiry_time = _get_certificate_expiry_time(certificate_dict["certificate"]) - if not expiry_time: - continue - time_difference = expiry_time - datetime.now(timezone.utc) - if time_difference.total_seconds() < 0: - logger.warning("Certificate is expired") - self.on.certificate_invalidated.emit( - reason="expired", - certificate=certificate_dict["certificate"], - certificate_signing_request=certificate_dict["certificate_signing_request"], - ca=certificate_dict["ca"], - chain=certificate_dict["chain"], - ) - self.request_certificate_revocation(certificate_dict["certificate"].encode()) - continue - if time_difference.total_seconds() < (self.expiry_notification_time * 60 * 60): - logger.warning("Certificate almost expired") - self.on.certificate_expiring.emit( - certificate=certificate_dict["certificate"], - expiry=expiry_time.isoformat(), - ) diff --git a/kubernetes/poetry.lock b/kubernetes/poetry.lock index 4014fe285..c0efb4465 100644 --- a/kubernetes/poetry.lock +++ b/kubernetes/poetry.lock @@ -212,7 +212,7 @@ version = "2.0.0" description = "Foreign Function Interface for Python calling C code." optional = false python-versions = ">=3.9" -groups = ["charm-libs", "integration"] +groups = ["main", "charm-libs", "integration"] files = [ {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, @@ -299,7 +299,7 @@ files = [ {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, ] -markers = {charm-libs = "platform_python_implementation != \"PyPy\""} +markers = {main = "platform_python_implementation != \"PyPy\"", charm-libs = "platform_python_implementation != \"PyPy\""} [package.dependencies] pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} @@ -369,6 +369,23 @@ files = [ dunamai = ">=1.23.1" tomlkit = ">=0.13.2" +[[package]] +name = "charmlibs-interfaces-tls-certificates" +version = "1.8.1" +description = "The charmlibs.interfaces.tls_certificates package." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "charmlibs_interfaces_tls_certificates-1.8.1-py3-none-any.whl", hash = "sha256:8e8fe047e02515d76f57a1d019056d72ce8c859c2ffb39a1e379cfc11fc048e6"}, + {file = "charmlibs_interfaces_tls_certificates-1.8.1.tar.gz", hash = "sha256:f2bfabf3a3b4c18034941771733177b30e4742c06d7742d4bb30da6ead953f43"}, +] + +[package.dependencies] +cryptography = ">=43.0.0" +ops = "*" +pydantic = "*" + [[package]] name = "charset-normalizer" version = "3.3.2" @@ -591,7 +608,7 @@ version = "46.0.6" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = "!=3.9.0,!=3.9.1,>=3.8" -groups = ["charm-libs", "integration"] +groups = ["main", "charm-libs", "integration"] files = [ {file = "cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8"}, {file = "cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30"}, @@ -1442,12 +1459,12 @@ version = "2.22" description = "C parser in Python" optional = false python-versions = ">=3.8" -groups = ["charm-libs", "integration"] +groups = ["main", "charm-libs", "integration"] files = [ {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, ] -markers = {charm-libs = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"", integration = "implementation_name != \"PyPy\""} +markers = {main = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"", charm-libs = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"", integration = "implementation_name != \"PyPy\""} [[package]] name = "pydantic" diff --git a/kubernetes/pyproject.toml b/kubernetes/pyproject.toml index 6de09d31d..6e2745cb1 100644 --- a/kubernetes/pyproject.toml +++ b/kubernetes/pyproject.toml @@ -5,6 +5,7 @@ main = [ "boto3~=1.28", "charm-refresh~=3.1.1", + "charmlibs-interfaces-tls-certificates~=1.0", "jinja2~=3.1", "lightkube~=0.19.0", "object-storage-charmlib~=1.0.0", From 2ac6b3c4892aeb2317953075aaa66e8268ec3491 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sinclert=20P=C3=A9rez?= Date: Wed, 15 Apr 2026 15:48:28 +0200 Subject: [PATCH 3/9] [K8s] Remove TLS charmlib --- kubernetes/actions.yaml | 10 - kubernetes/lib/charms/mysql/v0/tls.py | 260 -------------------------- 2 files changed, 270 deletions(-) delete mode 100644 kubernetes/lib/charms/mysql/v0/tls.py diff --git a/kubernetes/actions.yaml b/kubernetes/actions.yaml index bfbb88f21..3ed164ffc 100644 --- a/kubernetes/actions.yaml +++ b/kubernetes/actions.yaml @@ -33,16 +33,6 @@ set-password: type: string description: The password will be auto-generated if this option is not specified. -set-tls-private-key: - description: - Set the privates key, which will be used for certificate signing requests (CSR). Run - for each unit separately. - params: - internal-key: - type: string - description: The content of private key for internal communications with - clients. Content will be auto-generated if this option is not specified. - create-backup: description: Create a database backup using xtrabackup. S3 credentials are retrieved from a relation with the S3 integrator charm. diff --git a/kubernetes/lib/charms/mysql/v0/tls.py b/kubernetes/lib/charms/mysql/v0/tls.py deleted file mode 100644 index 4aef0499e..000000000 --- a/kubernetes/lib/charms/mysql/v0/tls.py +++ /dev/null @@ -1,260 +0,0 @@ -# Copyright 2023 Canonical Ltd. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Library containing the implementation of the tls certificates relation for mysql charm. - -This library is used by the mysql charm to provide the TLS certificates relation. -It requires the TLS certificates library and the MySQL library. - -""" - -import base64 -import logging -import re -import socket -import typing - -from charms.mysql.v0.mysql import MySQLKillSessionError, MySQLTLSSetupError -from charms.tls_certificates_interface.v2.tls_certificates import ( - CertificateAvailableEvent, - CertificateExpiringEvent, - TLSCertificatesRequiresV2, - generate_csr, - generate_private_key, -) -from mysql_shell.models import InstanceState -from ops.charm import ActionEvent -from ops.framework import Object -from ops.model import ActiveStatus, BlockedStatus, MaintenanceStatus - -from constants import ( - MYSQL_DATA_DIR, - TLS_RELATION, - TLS_SSL_CA_FILE, - TLS_SSL_CERT_FILE, - TLS_SSL_KEY_FILE, -) - -logger = logging.getLogger(__name__) - -LIBID = "eb73947deedd4380a3a90d527e0878eb" -LIBAPI = 0 -LIBPATCH = 12 - -PYDEPS = ["mysql_shell_client ~= 0.7"] - -SCOPE = "unit" - -if typing.TYPE_CHECKING: - from .mysql import MySQLCharmBase - - -class MySQLTLS(Object): - """MySQL TLS Provider class.""" - - def __init__(self, charm: "MySQLCharmBase"): - super().__init__(charm, "certificates") - self.charm = charm - - self.certs = TLSCertificatesRequiresV2(self.charm, TLS_RELATION) - - self.framework.observe( - self.charm.on.set_tls_private_key_action, - self._on_set_tls_private_key, - ) - self.framework.observe( - self.charm.on[TLS_RELATION].relation_joined, self._on_tls_relation_joined - ) - self.framework.observe( - self.charm.on[TLS_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) - - # ======================= - # Event Handlers - # ======================= - def _on_set_tls_private_key(self, event: ActionEvent) -> None: - """Action for setting a TLS private key.""" - self._request_certificate(event.params.get("internal-key", None)) - - def _on_tls_relation_joined(self, event) -> None: - """Request certificate when TLS relation joined.""" - if not self.charm.unit_initialized(): - event.defer() - return - self._request_certificate(None) - - def _on_certificate_available(self, event: CertificateAvailableEvent) -> None: - """Enable TLS when TLS certificate available.""" - if ( - event.certificate_signing_request.strip() - != self.charm.get_secret(SCOPE, "csr").strip() - ): - logger.error("An unknown certificate expiring.") - return - - if self.charm.unit_peer_data.get("tls") == "enabled": - logger.debug("TLS is already enabled.") - return - - state = self.charm._mysql.get_member_state() - if state != InstanceState.ONLINE: - logger.debug("Unit not initialized yet, deferring TLS configuration.") - event.defer() - return - - self.charm.unit.status = MaintenanceStatus("Setting up TLS") - - self.charm.set_secret( - SCOPE, "chain", "\n".join(event.chain) if event.chain is not None else None - ) - self.charm.set_secret(SCOPE, "certificate", event.certificate) - self.charm.set_secret(SCOPE, "certificate-authority", event.ca) - - self.push_tls_files_to_workload() - try: - self.charm._mysql.tls_setup( - ca_path=f"{MYSQL_DATA_DIR}/{TLS_SSL_CA_FILE}", - key_path=f"{MYSQL_DATA_DIR}/{TLS_SSL_KEY_FILE}", - cert_path=f"{MYSQL_DATA_DIR}/{TLS_SSL_CERT_FILE}", - require_tls=True, - ) - - # kill all sessions to force clients to reconnect - self.charm._mysql.kill_client_sessions() - except MySQLTLSSetupError: - logger.error("Failed to set custom TLS configuration.") - self.charm.unit.status = BlockedStatus("Failed to set TLS configuration.") - return - except MySQLKillSessionError: - logger.warning("Failed to kill unencrypted sessions.") - # set tls flag for unit - self.charm.unit_peer_data.update({"tls": "enabled"}) - self.charm.unit.status = ActiveStatus() - - def _on_certificate_expiring(self, event: CertificateExpiringEvent) -> None: - """Request the new certificate when old certificate is expiring.""" - if event.certificate != self.charm.get_secret(SCOPE, "certificate"): - logger.error("An unknown certificate expiring.") - return - - key = self.charm.get_secret(SCOPE, "key").encode("utf-8") - old_csr = self.charm.get_secret(SCOPE, "csr").encode("utf-8") - new_csr = generate_csr( - private_key=key, - subject=self.charm.get_unit_hostname(), - organization=self.charm.app.name, - sans=self._get_sans(), - ) - self.certs.request_certificate_renewal( - old_certificate_signing_request=old_csr, - new_certificate_signing_request=new_csr, - ) - self.charm.set_secret(SCOPE, "csr", new_csr.decode("utf-8")) - - def _on_tls_relation_broken(self, _) -> None: - """Disable TLS when TLS relation broken.""" - if self.charm.removing_unit: - logger.debug("Unit is being removed, skipping TLS cleanup.") - return - - try: - self.charm._mysql.tls_setup() - self.charm.unit_peer_data.pop("tls") - except MySQLTLSSetupError: - logger.error("Failed to restore default TLS configuration.") - self.charm.unit.status = BlockedStatus("Failed to restore default TLS configuration.") - - # ======================= - # Helpers - # ======================= - def _request_certificate(self, param: str | None): - """Request a certificate to TLS Certificates Operator.""" - key = generate_private_key() if param is None else self._parse_tls_file(param) - - csr = generate_csr( - private_key=key, - subject=self.charm.get_unit_hostname(), - organization=self.charm.app.name, - sans=self._get_sans(), - ) - - # store secrets - self.charm.set_secret(SCOPE, "key", key.decode("utf-8")) - self.charm.set_secret(SCOPE, "csr", csr.decode("utf-8")) - # set control flag - self.charm.unit_peer_data.update({"tls": "requested"}) - if self.charm.model.get_relation(TLS_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 ]+-+)", - "\n\\1\n", - raw_content, - ).encode("utf-8") - return base64.b64decode(raw_content) - - def _get_sans(self) -> list[str]: - """Create a list of DNS names for a unit. - - Returns: - A list representing the hostnames of the unit. - """ - unit_id = self.charm.unit.name.split("/")[1] - return [ - f"{self.charm.app.name}-{unit_id}", - socket.getfqdn(), - str(self.charm.model.get_binding(self.charm.peers).network.bind_address), - ] - - def get_tls_content(self) -> tuple[str | None, str | None, str | None]: - """Retrieve TLS content. - - Return TLS files as required by mysql. - - Returns: - A tuple of strings with the content of server-key, ca and server-cert - """ - ca = self.charm.get_secret(SCOPE, "certificate-authority") - chain = self.charm.get_secret(SCOPE, "chain") - ca_file = chain or ca - - key = self.charm.get_secret(SCOPE, "key") - cert = self.charm.get_secret(SCOPE, "certificate") - return key, ca_file, cert - - def push_tls_files_to_workload(self) -> None: - """Push TLS files to unit.""" - ssl_key, ssl_ca, ssl_cert = self.get_tls_content() - - if ssl_key: - self.charm._mysql.write_content_to_file( - f"{MYSQL_DATA_DIR}/{TLS_SSL_KEY_FILE}", ssl_key, permission=0o400 - ) - - if ssl_ca: - self.charm._mysql.write_content_to_file( - f"{MYSQL_DATA_DIR}/{TLS_SSL_CA_FILE}", ssl_ca, permission=0o400 - ) - - if ssl_cert: - self.charm._mysql.write_content_to_file( - f"{MYSQL_DATA_DIR}/{TLS_SSL_CERT_FILE}", ssl_cert, permission=0o400 - ) From 1421e39c4e65ca01c2a6f3ced8de5ac8a7659ee6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sinclert=20P=C3=A9rez?= Date: Wed, 15 Apr 2026 17:26:33 +0200 Subject: [PATCH 4/9] [K8s] Define TLS relation handler --- kubernetes/lib/charms/mysql/v0/mysql.py | 13 +- kubernetes/metadata.yaml | 2 +- kubernetes/src/charm.py | 20 +- kubernetes/src/constants.py | 2 +- kubernetes/src/relations/tls.py | 184 ++++++++++++++++++ kubernetes/terraform/outputs.tf | 8 +- .../tests/integration/integration/test_tls.py | 6 +- 7 files changed, 209 insertions(+), 26 deletions(-) create mode 100644 kubernetes/src/relations/tls.py diff --git a/kubernetes/lib/charms/mysql/v0/mysql.py b/kubernetes/lib/charms/mysql/v0/mysql.py index 9baba7e1a..c8b16bf40 100644 --- a/kubernetes/lib/charms/mysql/v0/mysql.py +++ b/kubernetes/lib/charms/mysql/v0/mysql.py @@ -1299,14 +1299,11 @@ def configure_mysql_system_roles(self) -> None: def drop_root_user(self) -> None: """Drop the root user from the instance.""" - logger.debug("Dropping root user after initial setup") - client = MySQLInstanceClient( - self._build_instance_tcp_executor(self.instance_address), - self._quoter, - ) user = User("root", "localhost") + try: - client.delete_instance_user(user) + logger.debug("Dropping root user after initial setup") + self._instance_client_tcp.delete_instance_user(user) except ExecutionError as e: logger.error(f"Failed to drop root user for {self.instance_address}") raise MySQLDropRootUserError() from e @@ -2916,14 +2913,14 @@ def _execute_commands( """Execute commands on the server where MySQL is running.""" raise NotImplementedError - def tls_setup( + def setup_client_tls( self, ca_path: str = "ca.pem", key_path: str = "server-key.pem", cert_path: str = "server-cert.pem", require_tls: bool = False, ) -> None: - """Setup TLS files and requirement mode.""" + """Setup client-connection TLS files and requirement mode.""" tls_var = "require_secure_transport" tls_val = "ON" if require_tls else "OFF" diff --git a/kubernetes/metadata.yaml b/kubernetes/metadata.yaml index a6e42f2c5..738d2bd4a 100644 --- a/kubernetes/metadata.yaml +++ b/kubernetes/metadata.yaml @@ -65,7 +65,7 @@ provides: limit: 1 requires: - certificates: + client-certificates: interface: tls-certificates limit: 1 optional: true diff --git a/kubernetes/src/charm.py b/kubernetes/src/charm.py index 8464dd48c..33eb1e89b 100755 --- a/kubernetes/src/charm.py +++ b/kubernetes/src/charm.py @@ -46,7 +46,6 @@ MySQLSetClusterPrimaryError, MySQLUnableToGetMemberStateError, ) -from charms.mysql.v0.tls import MySQLTLS from charms.prometheus_k8s.v0.prometheus_scrape import MetricsEndpointProvider from charms.rolling_ops.v0.rollingops import RollingOpsManager from object_storage import S3Requirer @@ -98,6 +97,7 @@ from mysql_k8s_helpers import MySQL, MySQLInitialiseMySQLDError from refresh import KubernetesMySQLRefresh from relations.mysql_provider import MySQLProvider +from relations.tls import TLS from rotate_mysql_logs import RotateMySQLLogs, RotateMySQLLogsCharmEvents from utils import compare_dictionaries, dotappend, generate_random_password, get_k8s_fqdn @@ -149,7 +149,7 @@ def __init__(self, *args): self.mysql_config = MySQLConfig() self.k8s_helpers = KubernetesHelpers(self) self.database_relation = MySQLProvider(self) - self.tls = MySQLTLS(self) + self.tls = TLS(self) self.s3_integrator = S3Requirer(self, S3_INTEGRATOR_RELATION_NAME) self.backups = MySQLBackups(self, self.s3_integrator) self.grafana_dashboards = GrafanaDashboardProvider(self) @@ -359,8 +359,12 @@ def get_unit_address(self, unit: Unit, relation_name: str = PEER) -> str: Translate juju unit name to resolvable hostname. """ - unit_hostname = self.get_unit_hostname(unit.name) - unit_dns_domain = get_k8s_fqdn(self.get_unit_hostname(unit.name)) + try: + unit_hostname = self.get_unit_hostname(unit.name) + unit_dns_domain = get_k8s_fqdn(unit_hostname) + except RuntimeError: + logger.warning("Unit DNS domain name is not propagated yet") + return "" # When fully propagated, DNS domain name should contain unit hostname. # For example: @@ -368,13 +372,11 @@ def get_unit_address(self, unit: Unit, relation_name: str = PEER) -> str: # Fully propagated: mysql-k8s-0.mysql-k8s-endpoints.dev.svc.cluster.local # Not propagated yet: 10-1-142-191.mysql-k8s.dev.svc.cluster.local if unit_hostname not in unit_dns_domain: - logger.warning( - "get_unit_address: unit DNS domain name is not fully propagated yet, trying again" - ) + logger.warning("Unit DNS domain name is not fully propagated yet.") raise RuntimeError("unit DNS domain name is not fully propagated yet") if unit_dns_domain == unit_hostname: - logger.warning("Can't get fully qualified domain name for unit. IS DNS not ready?") - raise RuntimeError("Can't get unit fqdn") + logger.warning("Can't get fully qualified domain name for unit") + raise RuntimeError("Can't get fully qualified domain name for unit") return dotappend(unit_dns_domain) diff --git a/kubernetes/src/constants.py b/kubernetes/src/constants.py index 238f6dd6e..f5ffc4f92 100644 --- a/kubernetes/src/constants.py +++ b/kubernetes/src/constants.py @@ -21,7 +21,7 @@ BACKUPS_PASSWORD_KEY = "backups-password" # noqa: S105 CONTAINER_RESTARTS = "unit-container-restarts" UNIT_ENDPOINTS_KEY = "unit-endpoints" -TLS_RELATION = "certificates" +TLS_CLIENT_RELATION = "client-certificates" TLS_SSL_CA_FILE = "custom-ca.pem" TLS_SSL_KEY_FILE = "custom-server-key.pem" TLS_SSL_CERT_FILE = "custom-server-cert.pem" diff --git a/kubernetes/src/relations/tls.py b/kubernetes/src/relations/tls.py new file mode 100644 index 000000000..681caa70e --- /dev/null +++ b/kubernetes/src/relations/tls.py @@ -0,0 +1,184 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +"""TLS Handler.""" + +import logging +import socket +from typing import TYPE_CHECKING + +from charmlibs.interfaces.tls_certificates import ( + CertificateRequestAttributes, + TLSCertificatesRequiresV4, +) +from charms.mysql.v0.mysql import MySQLTLSSetupError +from mysql_shell.models import InstanceState +from ops.framework import EventBase, EventSource, Object +from ops.model import BlockedStatus, MaintenanceStatus +from ops.pebble import ConnectionError as PebbleConnectionError +from ops.pebble import PathError, ProtocolError + +from constants import ( + DB_RELATION_NAME, + MYSQL_DATA_DIR, + TLS_CLIENT_RELATION, + TLS_SSL_CA_FILE, + TLS_SSL_CERT_FILE, + TLS_SSL_KEY_FILE, +) + +if TYPE_CHECKING: + from charm import MySQLOperatorCharm + +logger = logging.getLogger(__name__) + + +class RefreshTLSCertificatesEvent(EventBase): + """Event for refreshing TLS certificates.""" + + +class TLS(Object): + """In this class we manage certificates relation.""" + + refresh_tls_certificates_event = EventSource(RefreshTLSCertificatesEvent) + + def __init__(self, charm: "MySQLOperatorCharm"): + super().__init__(charm, "certificates") + self.charm = charm + self.unit_name = charm.unit.name.replace("/", "-") + self.unit_endpoints = f"{self.unit_name}.{charm.app.name}-endpoints" + + self._common_hosts = { + self.unit_name, + self.unit_endpoints, + } + if fqdn := socket.getfqdn(): + self._common_hosts.add(fqdn) + + self.client_certificate = TLSCertificatesRequiresV4( + self.charm, + TLS_CLIENT_RELATION, + certificate_requests=[ + CertificateRequestAttributes( + common_name=self._get_client_common_name(), + sans_dns={ + *self._common_hosts, + *self._get_client_addresses(), + }, + ), + ], + refresh_events=[self.refresh_tls_certificates_event], + ) + + self.framework.observe( + self.client_certificate.on.certificate_available, self._on_certificate_available + ) + self.framework.observe( + self.charm.on[TLS_CLIENT_RELATION].relation_broken, self._on_relation_broken + ) + + def _get_common_name(self) -> str: + """Get a common name for the certificate attributes.""" + if len(self.unit_endpoints) > 64: + return f"*.{self.charm.app.name}-endpoints" + + return self.unit_endpoints + + def _get_client_common_name(self) -> str: + """Get a common name for the client certificate attributes.""" + return self._get_common_name() + + def _get_client_addresses(self) -> set[str]: + """Get a set of client connection addresses for the certificate attributes.""" + client_addresses = set() + if addr := self.charm.get_unit_address(self.charm.unit, DB_RELATION_NAME): + client_addresses.add(addr) + + return client_addresses + + def _get_client_tls_files(self) -> tuple[str | None, str | None, str | None]: + """Prepare TLS files in special MySQL way. + + MySQL needs three files: + — CA file should have a full chain. + — Key file should have private key. + — Certificate file should have certificate without certificate chain. + """ + ca_file = None + cert_file = None + key_file = None + + certs, private_key = self.client_certificate.get_assigned_certificates() + if private_key: + key_file = str(private_key) + if certs: + cert_file = str(certs[0].certificate) + ca_file = str(certs[0].ca) + + return key_file, ca_file, cert_file + + def _on_certificate_available(self, event: EventBase) -> None: + """Handler for the certificate available event.""" + state = self.charm._mysql.get_member_state() + if state != InstanceState.ONLINE: + logger.debug("Unit not initialized yet, deferring TLS configuration.") + event.defer() + return + + self.charm.unit.status = MaintenanceStatus("Enabling TLS") + + try: + self._push_tls_files_to_workload() + except (PebbleConnectionError, PathError, ProtocolError) as e: + logger.error("Cannot push TLS certificates: %r", e) + event.defer() + return + + try: + self.charm._mysql.setup_client_tls( + ca_path=f"{MYSQL_DATA_DIR}/client_{TLS_SSL_CA_FILE}", + key_path=f"{MYSQL_DATA_DIR}/client_{TLS_SSL_KEY_FILE}", + cert_path=f"{MYSQL_DATA_DIR}/client_{TLS_SSL_CERT_FILE}", + require_tls=True, + ) + self.charm._mysql.kill_client_sessions() + except MySQLTLSSetupError: + logger.error("Failed to enable TLS configuration.") + self.charm.unit.status = BlockedStatus("Failed to enable TLS configuration.") + return + + self.charm.unit.status = self.charm.build_unit_workload_status() + + def _on_relation_broken(self, _: EventBase) -> None: + """Handler for the relation broken event.""" + if self.charm.removing_unit: + logger.debug("Unit is being removed, skipping TLS cleanup.") + return + + self.charm.unit.status = MaintenanceStatus("Disabling TLS") + + try: + self.charm._mysql.setup_client_tls() + self.charm._mysql.kill_client_sessions() + except MySQLTLSSetupError: + logger.error("Failed to disable TLS configuration.") + self.charm.unit.status = BlockedStatus("Failed to disable TLS configuration.") + return + + self.charm.unit.status = self.charm.build_unit_workload_status() + + def _push_tls_files_to_workload(self) -> None: + """Push TLS files to unit.""" + key, ca, cert = self._get_client_tls_files() + if key: + self.charm._mysql.write_content_to_file( + f"{MYSQL_DATA_DIR}/client_{TLS_SSL_KEY_FILE}", key, permission=0o400 + ) + if ca: + self.charm._mysql.write_content_to_file( + f"{MYSQL_DATA_DIR}/client_{TLS_SSL_CA_FILE}", ca, permission=0o400 + ) + if cert: + self.charm._mysql.write_content_to_file( + f"{MYSQL_DATA_DIR}/client_{TLS_SSL_CERT_FILE}", cert, permission=0o400 + ) diff --git a/kubernetes/terraform/outputs.tf b/kubernetes/terraform/outputs.tf index 838cbbf0e..e5d9c34a9 100644 --- a/kubernetes/terraform/outputs.tf +++ b/kubernetes/terraform/outputs.tf @@ -15,9 +15,9 @@ output "provides" { output "requires" { description = "Map of all the required endpoints" value = { - certificates = "certificates" - s3_parameters = "s3-parameters" - logging = "logging" - tracing = "tracing" + client_certificates = "client-certificates" + s3_parameters = "s3-parameters" + logging = "logging" + tracing = "tracing" } } diff --git a/kubernetes/tests/integration/integration/test_tls.py b/kubernetes/tests/integration/integration/test_tls.py index a0f0c933e..e96dd612d 100644 --- a/kubernetes/tests/integration/integration/test_tls.py +++ b/kubernetes/tests/integration/integration/test_tls.py @@ -103,7 +103,7 @@ def test_enable_tls(juju: Juju) -> None: # Relate with TLS charm logger.info("Relate to TLS operator") - juju.integrate(APP_NAME, TLS_APP_NAME) + juju.integrate(f"{APP_NAME}:client-certificates", f"{TLS_APP_NAME}:certificates") # allow time for TLS enablement sleep(TLS_SETUP_SLEEP_TIME) @@ -180,7 +180,7 @@ def test_disable_tls(juju: Juju) -> None: app_units = get_app_units(juju, APP_NAME) logger.info("Removing relation") - juju.remove_relation(f"{APP_NAME}:certificates", f"{TLS_APP_NAME}:certificates") + juju.remove_relation(f"{APP_NAME}:client-certificates", f"{TLS_APP_NAME}:certificates") # Allow time for reconfigure sleep(TLS_SETUP_SLEEP_TIME) @@ -214,7 +214,7 @@ def get_tls_ca(juju: Juju, unit_name: str) -> str: # Filter the data based on the relation name. relation_data = [ - v for v in unit_info[unit_name]["relation-info"] if v["endpoint"] == "certificates" + v for v in unit_info[unit_name]["relation-info"] if v["endpoint"] == "client-certificates" ] if len(relation_data) == 0: return "" From 1232ed976613c4185e9c0299286ba774cf797727 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sinclert=20P=C3=A9rez?= Date: Wed, 15 Apr 2026 17:28:28 +0200 Subject: [PATCH 5/9] [K8s] Disable TLS rotation test --- .../tests/integration/integration/test_tls.py | 73 +------------------ 1 file changed, 1 insertion(+), 72 deletions(-) diff --git a/kubernetes/tests/integration/integration/test_tls.py b/kubernetes/tests/integration/integration/test_tls.py index e96dd612d..77243d6c3 100644 --- a/kubernetes/tests/integration/integration/test_tls.py +++ b/kubernetes/tests/integration/integration/test_tls.py @@ -8,7 +8,7 @@ import jubilant from jubilant import Juju -from constants import CONTAINER_NAME, MYSQL_DATA_DIR, REPLICATION_USERNAME, TLS_SSL_CERT_FILE +from constants import REPLICATION_USERNAME from ..helpers import is_connection_possible from ..helpers_ha import ( @@ -126,55 +126,6 @@ def test_enable_tls(juju: Juju) -> None: assert get_tls_ca(juju, app_units[0]), "❌ No CA found after TLS relation" -def test_rotate_tls_key(juju: Juju) -> None: - """Verify rotating tls private keys restarts cluster with new certificates. - - This test rotates tls private keys to randomly generated keys. - """ - app_units = get_app_units(juju, APP_NAME) - # dict of values for cert file md5sum. After resetting the - # private keys these certificates should be updated. - original_tls = {} - for unit_name in app_units: - original_tls[unit_name] = {} - original_tls[unit_name]["cert"] = unit_file_md5( - juju, unit_name, f"{MYSQL_DATA_DIR}/{TLS_SSL_CERT_FILE}" - ) - - # set key using auto-generated key for each unit - for unit_name in app_units: - task = juju.run( - unit=unit_name, - action="set-tls-private-key", - ) - task.raise_on_failure() - - # allow time for reconfiguration - sleep(TLS_SETUP_SLEEP_TIME) - - # After updating both the external key and the internal key a new certificate request will be - # made; then the certificates should be available and updated. - for unit_name in app_units: - new_cert_md5 = unit_file_md5(juju, unit_name, f"{MYSQL_DATA_DIR}/{TLS_SSL_CERT_FILE}") - - assert new_cert_md5 != original_tls[unit_name]["cert"], ( - f"cert for {unit_name} was not updated." - ) - - # Asserting only encrypted connection should be possible - logger.info("Asserting connections after relation") - for unit_name in app_units: - unit_ip = get_unit_address(juju, APP_NAME, unit_name) - config["host"] = unit_ip - assert is_connection_possible(config, **{"ssl_disabled": False}), ( - f"❌ Encrypted connection not possible to unit {unit_name} with enabled TLS" - ) - - assert not is_connection_possible(config, **{"ssl_disabled": True}), ( - f"❌ Unencrypted connection possible to unit {unit_name} with enabled TLS" - ) - - def test_disable_tls(juju: Juju) -> None: # Remove the relation app_units = get_app_units(juju, APP_NAME) @@ -219,25 +170,3 @@ def get_tls_ca(juju: Juju, unit_name: str) -> str: if len(relation_data) == 0: return "" return json.loads(relation_data[0]["application-data"]["certificates"])[0].get("ca") - - -def unit_file_md5(juju: Juju, unit_name: str, file_path: str) -> str | None: - """Return md5 hash for given file. - - Args: - juju: The Juju instance - unit_name: The name of the unit - file_path: The path to the file - - Returns: - md5sum hash string - """ - try: - md5sum_raw = juju.ssh( - command=f"md5sum {file_path}", - target=unit_name, - container=CONTAINER_NAME, - ) - return md5sum_raw.strip().split()[0] - except Exception: - return From 06dea967da27509ae6a325b8b7f206afc40319ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sinclert=20P=C3=A9rez?= Date: Wed, 15 Apr 2026 17:31:34 +0200 Subject: [PATCH 6/9] [VM] Replace TLS charmlib by PyPi lib --- .../v2/tls_certificates.py | 1959 ----------------- machines/poetry.lock | 27 +- machines/pyproject.toml | 1 + 3 files changed, 23 insertions(+), 1964 deletions(-) delete mode 100644 machines/lib/charms/tls_certificates_interface/v2/tls_certificates.py diff --git a/machines/lib/charms/tls_certificates_interface/v2/tls_certificates.py b/machines/lib/charms/tls_certificates_interface/v2/tls_certificates.py deleted file mode 100644 index c232362fe..000000000 --- a/machines/lib/charms/tls_certificates_interface/v2/tls_certificates.py +++ /dev/null @@ -1,1959 +0,0 @@ -# Copyright 2021 Canonical Ltd. -# See LICENSE file for licensing details. - - -"""Library for the tls-certificates relation. - -This library contains the Requires and Provides classes for handling the tls-certificates -interface. - -## Getting Started -From a charm directory, fetch the library using `charmcraft`: - -```shell -charmcraft fetch-lib charms.tls_certificates_interface.v2.tls_certificates -``` - -Add the following libraries to the charm's `requirements.txt` file: -- jsonschema -- cryptography - -Add the following section to the charm's `charmcraft.yaml` file: -```yaml -parts: - charm: - build-packages: - - libffi-dev - - libssl-dev - - rustc - - cargo -``` - -### Provider charm -The provider charm is the charm providing certificates to another charm that requires them. In -this example, the provider charm is storing its private key using a peer relation interface called -`replicas`. - -Example: -```python -from charms.tls_certificates_interface.v2.tls_certificates import ( - CertificateCreationRequestEvent, - CertificateRevocationRequestEvent, - TLSCertificatesProvidesV2, - generate_private_key, -) -from ops.charm import CharmBase, InstallEvent -from ops.main import main -from ops.model import ActiveStatus, WaitingStatus - - -def generate_ca(private_key: bytes, subject: str) -> str: - return "whatever ca content" - - -def generate_certificate(ca: str, private_key: str, csr: str) -> str: - return "Whatever certificate" - - -class ExampleProviderCharm(CharmBase): - - def __init__(self, *args): - super().__init__(*args) - self.certificates = TLSCertificatesProvidesV2(self, "certificates") - self.framework.observe( - self.certificates.on.certificate_request, - self._on_certificate_request - ) - self.framework.observe( - self.certificates.on.certificate_revocation_request, - self._on_certificate_revocation_request - ) - self.framework.observe(self.on.install, self._on_install) - - def _on_install(self, event: InstallEvent) -> None: - private_key_password = b"banana" - private_key = generate_private_key(password=private_key_password) - ca_certificate = generate_ca(private_key=private_key, subject="whatever") - replicas_relation = self.model.get_relation("replicas") - if not replicas_relation: - self.unit.status = WaitingStatus("Waiting for peer relation to be created") - event.defer() - return - replicas_relation.data[self.app].update( - { - "private_key_password": "banana", - "private_key": private_key, - "ca_certificate": ca_certificate, - } - ) - self.unit.status = ActiveStatus() - - def _on_certificate_request(self, event: CertificateCreationRequestEvent) -> None: - replicas_relation = self.model.get_relation("replicas") - if not replicas_relation: - self.unit.status = WaitingStatus("Waiting for peer relation to be created") - event.defer() - return - ca_certificate = replicas_relation.data[self.app].get("ca_certificate") - private_key = replicas_relation.data[self.app].get("private_key") - certificate = generate_certificate( - ca=ca_certificate, - private_key=private_key, - csr=event.certificate_signing_request, - ) - - self.certificates.set_relation_certificate( - certificate=certificate, - certificate_signing_request=event.certificate_signing_request, - ca=ca_certificate, - chain=[ca_certificate, certificate], - relation_id=event.relation_id, - ) - - def _on_certificate_revocation_request(self, event: CertificateRevocationRequestEvent) -> None: - # Do what you want to do with this information - pass - - -if __name__ == "__main__": - main(ExampleProviderCharm) -``` - -### Requirer charm -The requirer charm is the charm requiring certificates from another charm that provides them. In -this example, the requirer charm is storing its certificates using a peer relation interface called -`replicas`. - -Example: -```python -from charms.tls_certificates_interface.v2.tls_certificates import ( - CertificateAvailableEvent, - CertificateExpiringEvent, - CertificateRevokedEvent, - TLSCertificatesRequiresV2, - generate_csr, - generate_private_key, -) -from ops.charm import CharmBase, RelationJoinedEvent -from ops.main import main -from ops.model import ActiveStatus, WaitingStatus -from typing import Union - - -class ExampleRequirerCharm(CharmBase): - - def __init__(self, *args): - super().__init__(*args) - self.cert_subject = "whatever" - self.certificates = TLSCertificatesRequiresV2(self, "certificates") - self.framework.observe(self.on.install, self._on_install) - self.framework.observe( - self.on.certificates_relation_joined, self._on_certificates_relation_joined - ) - self.framework.observe( - self.certificates.on.certificate_available, self._on_certificate_available - ) - self.framework.observe( - self.certificates.on.certificate_expiring, self._on_certificate_expiring - ) - self.framework.observe( - self.certificates.on.certificate_invalidated, self._on_certificate_invalidated - ) - self.framework.observe( - self.certificates.on.all_certificates_invalidated, - self._on_all_certificates_invalidated - ) - - def _on_install(self, event) -> None: - private_key_password = b"banana" - private_key = generate_private_key(password=private_key_password) - replicas_relation = self.model.get_relation("replicas") - if not replicas_relation: - self.unit.status = WaitingStatus("Waiting for peer relation to be created") - event.defer() - return - replicas_relation.data[self.app].update( - {"private_key_password": "banana", "private_key": private_key.decode()} - ) - - def _on_certificates_relation_joined(self, event: RelationJoinedEvent) -> None: - replicas_relation = self.model.get_relation("replicas") - if not replicas_relation: - self.unit.status = WaitingStatus("Waiting for peer relation to be created") - event.defer() - return - private_key_password = replicas_relation.data[self.app].get("private_key_password") - private_key = replicas_relation.data[self.app].get("private_key") - csr = generate_csr( - private_key=private_key.encode(), - private_key_password=private_key_password.encode(), - subject=self.cert_subject, - ) - replicas_relation.data[self.app].update({"csr": csr.decode()}) - self.certificates.request_certificate_creation(certificate_signing_request=csr) - - def _on_certificate_available(self, event: CertificateAvailableEvent) -> None: - replicas_relation = self.model.get_relation("replicas") - if not replicas_relation: - self.unit.status = WaitingStatus("Waiting for peer relation to be created") - event.defer() - return - replicas_relation.data[self.app].update({"certificate": event.certificate}) - replicas_relation.data[self.app].update({"ca": event.ca}) - replicas_relation.data[self.app].update({"chain": event.chain}) - self.unit.status = ActiveStatus() - - def _on_certificate_expiring( - self, event: Union[CertificateExpiringEvent, CertificateInvalidatedEvent] - ) -> None: - replicas_relation = self.model.get_relation("replicas") - if not replicas_relation: - self.unit.status = WaitingStatus("Waiting for peer relation to be created") - event.defer() - return - old_csr = replicas_relation.data[self.app].get("csr") - private_key_password = replicas_relation.data[self.app].get("private_key_password") - private_key = replicas_relation.data[self.app].get("private_key") - new_csr = generate_csr( - private_key=private_key.encode(), - private_key_password=private_key_password.encode(), - subject=self.cert_subject, - ) - self.certificates.request_certificate_renewal( - old_certificate_signing_request=old_csr, - new_certificate_signing_request=new_csr, - ) - replicas_relation.data[self.app].update({"csr": new_csr.decode()}) - - def _certificate_revoked(self) -> None: - old_csr = replicas_relation.data[self.app].get("csr") - private_key_password = replicas_relation.data[self.app].get("private_key_password") - private_key = replicas_relation.data[self.app].get("private_key") - new_csr = generate_csr( - private_key=private_key.encode(), - private_key_password=private_key_password.encode(), - subject=self.cert_subject, - ) - self.certificates.request_certificate_renewal( - old_certificate_signing_request=old_csr, - new_certificate_signing_request=new_csr, - ) - replicas_relation.data[self.app].update({"csr": new_csr.decode()}) - replicas_relation.data[self.app].pop("certificate") - replicas_relation.data[self.app].pop("ca") - replicas_relation.data[self.app].pop("chain") - self.unit.status = WaitingStatus("Waiting for new certificate") - - def _on_certificate_invalidated(self, event: CertificateInvalidatedEvent) -> None: - replicas_relation = self.model.get_relation("replicas") - if not replicas_relation: - self.unit.status = WaitingStatus("Waiting for peer relation to be created") - event.defer() - return - if event.reason == "revoked": - self._certificate_revoked() - if event.reason == "expired": - self._on_certificate_expiring(event) - - def _on_all_certificates_invalidated(self, event: AllCertificatesInvalidatedEvent) -> None: - # Do what you want with this information, probably remove all certificates. - pass - - -if __name__ == "__main__": - main(ExampleRequirerCharm) -``` - -You can relate both charms by running: - -```bash -juju relate -``` - -""" # noqa: D405, D410, D411, D214, D416 - -import copy -import json -import logging -import uuid -from contextlib import suppress -from datetime import datetime, timedelta, timezone -from ipaddress import IPv4Address -from typing import Any, Dict, List, Literal, Optional, Union - -from cryptography import x509 -from cryptography.hazmat._oid import ExtensionOID -from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import rsa -from cryptography.hazmat.primitives.serialization import pkcs12 -from jsonschema import exceptions, validate -from ops.charm import ( - CharmBase, - CharmEvents, - RelationBrokenEvent, - RelationChangedEvent, - SecretExpiredEvent, - UpdateStatusEvent, -) -from ops.framework import EventBase, EventSource, Handle, Object -from ops.jujuversion import JujuVersion -from ops.model import ModelError, Relation, RelationDataContent, SecretNotFoundError - -# The unique Charmhub library identifier, never change it -LIBID = "afd8c2bccf834997afce12c2706d2ede" - -# Increment this major API version when introducing breaking changes -LIBAPI = 2 - -# Increment this PATCH version before using `charmcraft publish-lib` or reset -# to 0 if you are raising the major API version -LIBPATCH = 29 - -PYDEPS = ["cryptography", "jsonschema"] - -REQUIRER_JSON_SCHEMA = { - "$schema": "http://json-schema.org/draft-04/schema#", - "$id": "https://canonical.github.io/charm-relation-interfaces/interfaces/tls_certificates/v1/schemas/requirer.json", - "type": "object", - "title": "`tls_certificates` requirer root schema", - "description": "The `tls_certificates` root schema comprises the entire requirer databag for this interface.", # noqa: E501 - "examples": [ - { - "certificate_signing_requests": [ - { - "certificate_signing_request": "-----BEGIN CERTIFICATE REQUEST-----\\nMIICWjCCAUICAQAwFTETMBEGA1UEAwwKYmFuYW5hLmNvbTCCASIwDQYJKoZIhvcN\\nAQEBBQADggEPADCCAQoCggEBANWlx9wE6cW7Jkb4DZZDOZoEjk1eDBMJ+8R4pyKp\\nFBeHMl1SQSDt6rAWsrfL3KOGiIHqrRY0B5H6c51L8LDuVrJG0bPmyQ6rsBo3gVke\\nDSivfSLtGvHtp8lwYnIunF8r858uYmblAR0tdXQNmnQvm+6GERvURQ6sxpgZ7iLC\\npPKDoPt+4GKWL10FWf0i82FgxWC2KqRZUtNbgKETQuARLig7etBmCnh20zmynorA\\ncY7vrpTPAaeQpGLNqqYvKV9W6yWVY08V+nqARrFrjk3vSioZSu8ZJUdZ4d9++SGl\\nbH7A6e77YDkX9i/dQ3Pa/iDtWO3tXS2MvgoxX1iSWlGNOHcCAwEAAaAAMA0GCSqG\\nSIb3DQEBCwUAA4IBAQCW1fKcHessy/ZhnIwAtSLznZeZNH8LTVOzkhVd4HA7EJW+\\nKVLBx8DnN7L3V2/uPJfHiOg4Rx7fi7LkJPegl3SCqJZ0N5bQS/KvDTCyLG+9E8Y+\\n7wqCmWiXaH1devimXZvazilu4IC2dSks2D8DPWHgsOdVks9bme8J3KjdNMQudegc\\newWZZ1Dtbd+Rn7cpKU3jURMwm4fRwGxbJ7iT5fkLlPBlyM/yFEik4SmQxFYrZCQg\\n0f3v4kBefTh5yclPy5tEH+8G0LMsbbo3dJ5mPKpAShi0QEKDLd7eR1R/712lYTK4\\ndi4XaEfqERgy68O4rvb4PGlJeRGS7AmL7Ss8wfAq\\n-----END CERTIFICATE REQUEST-----\\n" # noqa: E501 - }, - { - "certificate_signing_request": "-----BEGIN CERTIFICATE REQUEST-----\\nMIICWjCCAUICAQAwFTETMBEGA1UEAwwKYmFuYW5hLmNvbTCCASIwDQYJKoZIhvcN\\nAQEBBQADggEPADCCAQoCggEBAMk3raaX803cHvzlBF9LC7KORT46z4VjyU5PIaMb\\nQLIDgYKFYI0n5hf2Ra4FAHvOvEmW7bjNlHORFEmvnpcU5kPMNUyKFMTaC8LGmN8z\\nUBH3aK+0+FRvY4afn9tgj5435WqOG9QdoDJ0TJkjJbJI9M70UOgL711oU7ql6HxU\\n4d2ydFK9xAHrBwziNHgNZ72L95s4gLTXf0fAHYf15mDA9U5yc+YDubCKgTXzVySQ\\nUx73VCJLfC/XkZIh559IrnRv5G9fu6BMLEuBwAz6QAO4+/XidbKWN4r2XSq5qX4n\\n6EPQQWP8/nd4myq1kbg6Q8w68L/0YdfjCmbyf2TuoWeImdUCAwEAAaAAMA0GCSqG\\nSIb3DQEBCwUAA4IBAQBIdwraBvpYo/rl5MH1+1Um6HRg4gOdQPY5WcJy9B9tgzJz\\nittRSlRGTnhyIo6fHgq9KHrmUthNe8mMTDailKFeaqkVNVvk7l0d1/B90Kz6OfmD\\nxN0qjW53oP7y3QB5FFBM8DjqjmUnz5UePKoX4AKkDyrKWxMwGX5RoET8c/y0y9jp\\nvSq3Wh5UpaZdWbe1oVY8CqMVUEVQL2DPjtopxXFz2qACwsXkQZxWmjvZnRiP8nP8\\nbdFaEuh9Q6rZ2QdZDEtrU4AodPU3NaukFr5KlTUQt3w/cl+5//zils6G5zUWJ2pN\\ng7+t9PTvXHRkH+LnwaVnmsBFU2e05qADQbfIn7JA\\n-----END CERTIFICATE REQUEST-----\\n" # noqa: E501 - }, - ] - } - ], - "properties": { - "certificate_signing_requests": { - "type": "array", - "items": { - "type": "object", - "properties": { - "certificate_signing_request": {"type": "string"}, - "ca": {"type": "boolean"}, - }, - "required": ["certificate_signing_request"], - }, - } - }, - "required": ["certificate_signing_requests"], - "additionalProperties": True, -} - -PROVIDER_JSON_SCHEMA = { - "$schema": "http://json-schema.org/draft-04/schema#", - "$id": "https://canonical.github.io/charm-relation-interfaces/interfaces/tls_certificates/v1/schemas/provider.json", - "type": "object", - "title": "`tls_certificates` provider root schema", - "description": "The `tls_certificates` root schema comprises the entire provider databag for this interface.", # noqa: E501 - "examples": [ - { - "certificates": [ - { - "ca": "-----BEGIN CERTIFICATE-----\\nMIIDJTCCAg2gAwIBAgIUMsSK+4FGCjW6sL/EXMSxColmKw8wDQYJKoZIhvcNAQEL\\nBQAwIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdoYXRldmVyMB4XDTIyMDcyOTIx\\nMTgyN1oXDTIzMDcyOTIxMTgyN1owIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdo\\nYXRldmVyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA55N9DkgFWbJ/\\naqcdQhso7n1kFvt6j/fL1tJBvRubkiFMQJnZFtekfalN6FfRtA3jq+nx8o49e+7t\\nLCKT0xQ+wufXfOnxv6/if6HMhHTiCNPOCeztUgQ2+dfNwRhYYgB1P93wkUVjwudK\\n13qHTTZ6NtEF6EzOqhOCe6zxq6wrr422+ZqCvcggeQ5tW9xSd/8O1vNID/0MTKpy\\nET3drDtBfHmiUEIBR3T3tcy6QsIe4Rz/2sDinAcM3j7sG8uY6drh8jY3PWar9til\\nv2l4qDYSU8Qm5856AB1FVZRLRJkLxZYZNgreShAIYgEd0mcyI2EO/UvKxsIcxsXc\\nd45GhGpKkwIDAQABo1cwVTAfBgNVHQ4EGAQWBBRXBrXKh3p/aFdQjUcT/UcvICBL\\nODAhBgNVHSMEGjAYgBYEFFcGtcqHen9oV1CNRxP9Ry8gIEs4MA8GA1UdEwEB/wQF\\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAGmCEvcoFUrT9e133SHkgF/ZAgzeIziO\\nBjfAdU4fvAVTVfzaPm0yBnGqzcHyacCzbZjKQpaKVgc5e6IaqAQtf6cZJSCiJGhS\\nJYeosWrj3dahLOUAMrXRr8G/Ybcacoqc+osKaRa2p71cC3V6u2VvcHRV7HDFGJU7\\noijbdB+WhqET6Txe67rxZCJG9Ez3EOejBJBl2PJPpy7m1Ml4RR+E8YHNzB0lcBzc\\nEoiJKlDfKSO14E2CPDonnUoWBJWjEvJys3tbvKzsRj2fnLilytPFU0gH3cEjCopi\\nzFoWRdaRuNHYCqlBmso1JFDl8h4fMmglxGNKnKRar0WeGyxb4xXBGpI=\\n-----END CERTIFICATE-----\\n", # noqa: E501 - "chain": [ - "-----BEGIN CERTIFICATE-----\\nMIIDJTCCAg2gAwIBAgIUMsSK+4FGCjW6sL/EXMSxColmKw8wDQYJKoZIhvcNAQEL\\nBQAwIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdoYXRldmVyMB4XDTIyMDcyOTIx\\nMTgyN1oXDTIzMDcyOTIxMTgyN1owIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdo\\nYXRldmVyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA55N9DkgFWbJ/\\naqcdQhso7n1kFvt6j/fL1tJBvRubkiFMQJnZFtekfalN6FfRtA3jq+nx8o49e+7t\\nLCKT0xQ+wufXfOnxv6/if6HMhHTiCNPOCeztUgQ2+dfNwRhYYgB1P93wkUVjwudK\\n13qHTTZ6NtEF6EzOqhOCe6zxq6wrr422+ZqCvcggeQ5tW9xSd/8O1vNID/0MTKpy\\nET3drDtBfHmiUEIBR3T3tcy6QsIe4Rz/2sDinAcM3j7sG8uY6drh8jY3PWar9til\\nv2l4qDYSU8Qm5856AB1FVZRLRJkLxZYZNgreShAIYgEd0mcyI2EO/UvKxsIcxsXc\\nd45GhGpKkwIDAQABo1cwVTAfBgNVHQ4EGAQWBBRXBrXKh3p/aFdQjUcT/UcvICBL\\nODAhBgNVHSMEGjAYgBYEFFcGtcqHen9oV1CNRxP9Ry8gIEs4MA8GA1UdEwEB/wQF\\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAGmCEvcoFUrT9e133SHkgF/ZAgzeIziO\\nBjfAdU4fvAVTVfzaPm0yBnGqzcHyacCzbZjKQpaKVgc5e6IaqAQtf6cZJSCiJGhS\\nJYeosWrj3dahLOUAMrXRr8G/Ybcacoqc+osKaRa2p71cC3V6u2VvcHRV7HDFGJU7\\noijbdB+WhqET6Txe67rxZCJG9Ez3EOejBJBl2PJPpy7m1Ml4RR+E8YHNzB0lcBzc\\nEoiJKlDfKSO14E2CPDonnUoWBJWjEvJys3tbvKzsRj2fnLilytPFU0gH3cEjCopi\\nzFoWRdaRuNHYCqlBmso1JFDl8h4fMmglxGNKnKRar0WeGyxb4xXBGpI=\\n-----END CERTIFICATE-----\\n" # noqa: E501, W505 - ], - "certificate_signing_request": "-----BEGIN CERTIFICATE REQUEST-----\nMIICWjCCAUICAQAwFTETMBEGA1UEAwwKYmFuYW5hLmNvbTCCASIwDQYJKoZIhvcN\nAQEBBQADggEPADCCAQoCggEBANWlx9wE6cW7Jkb4DZZDOZoEjk1eDBMJ+8R4pyKp\nFBeHMl1SQSDt6rAWsrfL3KOGiIHqrRY0B5H6c51L8LDuVrJG0bPmyQ6rsBo3gVke\nDSivfSLtGvHtp8lwYnIunF8r858uYmblAR0tdXQNmnQvm+6GERvURQ6sxpgZ7iLC\npPKDoPt+4GKWL10FWf0i82FgxWC2KqRZUtNbgKETQuARLig7etBmCnh20zmynorA\ncY7vrpTPAaeQpGLNqqYvKV9W6yWVY08V+nqARrFrjk3vSioZSu8ZJUdZ4d9++SGl\nbH7A6e77YDkX9i/dQ3Pa/iDtWO3tXS2MvgoxX1iSWlGNOHcCAwEAAaAAMA0GCSqG\nSIb3DQEBCwUAA4IBAQCW1fKcHessy/ZhnIwAtSLznZeZNH8LTVOzkhVd4HA7EJW+\nKVLBx8DnN7L3V2/uPJfHiOg4Rx7fi7LkJPegl3SCqJZ0N5bQS/KvDTCyLG+9E8Y+\n7wqCmWiXaH1devimXZvazilu4IC2dSks2D8DPWHgsOdVks9bme8J3KjdNMQudegc\newWZZ1Dtbd+Rn7cpKU3jURMwm4fRwGxbJ7iT5fkLlPBlyM/yFEik4SmQxFYrZCQg\n0f3v4kBefTh5yclPy5tEH+8G0LMsbbo3dJ5mPKpAShi0QEKDLd7eR1R/712lYTK4\ndi4XaEfqERgy68O4rvb4PGlJeRGS7AmL7Ss8wfAq\n-----END CERTIFICATE REQUEST-----\n", # noqa: E501 - "certificate": "-----BEGIN CERTIFICATE-----\nMIICvDCCAaQCFFPAOD7utDTsgFrm0vS4We18OcnKMA0GCSqGSIb3DQEBCwUAMCAx\nCzAJBgNVBAYTAlVTMREwDwYDVQQDDAh3aGF0ZXZlcjAeFw0yMjA3MjkyMTE5Mzha\nFw0yMzA3MjkyMTE5MzhaMBUxEzARBgNVBAMMCmJhbmFuYS5jb20wggEiMA0GCSqG\nSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDVpcfcBOnFuyZG+A2WQzmaBI5NXgwTCfvE\neKciqRQXhzJdUkEg7eqwFrK3y9yjhoiB6q0WNAeR+nOdS/Cw7layRtGz5skOq7Aa\nN4FZHg0or30i7Rrx7afJcGJyLpxfK/OfLmJm5QEdLXV0DZp0L5vuhhEb1EUOrMaY\nGe4iwqTyg6D7fuBili9dBVn9IvNhYMVgtiqkWVLTW4ChE0LgES4oO3rQZgp4dtM5\nsp6KwHGO766UzwGnkKRizaqmLylfVusllWNPFfp6gEaxa45N70oqGUrvGSVHWeHf\nfvkhpWx+wOnu+2A5F/Yv3UNz2v4g7Vjt7V0tjL4KMV9YklpRjTh3AgMBAAEwDQYJ\nKoZIhvcNAQELBQADggEBAChjRzuba8zjQ7NYBVas89Oy7u++MlS8xWxh++yiUsV6\nWMk3ZemsPtXc1YmXorIQohtxLxzUPm2JhyzFzU/sOLmJQ1E/l+gtZHyRCwsb20fX\nmphuJsMVd7qv/GwEk9PBsk2uDqg4/Wix0Rx5lf95juJP7CPXQJl5FQauf3+LSz0y\nwF/j+4GqvrwsWr9hKOLmPdkyKkR6bHKtzzsxL9PM8GnElk2OpaPMMnzbL/vt2IAt\nxK01ZzPxCQCzVwHo5IJO5NR/fIyFbEPhxzG17QsRDOBR9fl9cOIvDeSO04vyZ+nz\n+kA2c3fNrZFAtpIlOOmFh8Q12rVL4sAjI5mVWnNEgvI=\n-----END CERTIFICATE-----\n", # noqa: E501 - } - ] - }, - { - "certificates": [ - { - "ca": "-----BEGIN CERTIFICATE-----\\nMIIDJTCCAg2gAwIBAgIUMsSK+4FGCjW6sL/EXMSxColmKw8wDQYJKoZIhvcNAQEL\\nBQAwIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdoYXRldmVyMB4XDTIyMDcyOTIx\\nMTgyN1oXDTIzMDcyOTIxMTgyN1owIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdo\\nYXRldmVyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA55N9DkgFWbJ/\\naqcdQhso7n1kFvt6j/fL1tJBvRubkiFMQJnZFtekfalN6FfRtA3jq+nx8o49e+7t\\nLCKT0xQ+wufXfOnxv6/if6HMhHTiCNPOCeztUgQ2+dfNwRhYYgB1P93wkUVjwudK\\n13qHTTZ6NtEF6EzOqhOCe6zxq6wrr422+ZqCvcggeQ5tW9xSd/8O1vNID/0MTKpy\\nET3drDtBfHmiUEIBR3T3tcy6QsIe4Rz/2sDinAcM3j7sG8uY6drh8jY3PWar9til\\nv2l4qDYSU8Qm5856AB1FVZRLRJkLxZYZNgreShAIYgEd0mcyI2EO/UvKxsIcxsXc\\nd45GhGpKkwIDAQABo1cwVTAfBgNVHQ4EGAQWBBRXBrXKh3p/aFdQjUcT/UcvICBL\\nODAhBgNVHSMEGjAYgBYEFFcGtcqHen9oV1CNRxP9Ry8gIEs4MA8GA1UdEwEB/wQF\\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAGmCEvcoFUrT9e133SHkgF/ZAgzeIziO\\nBjfAdU4fvAVTVfzaPm0yBnGqzcHyacCzbZjKQpaKVgc5e6IaqAQtf6cZJSCiJGhS\\nJYeosWrj3dahLOUAMrXRr8G/Ybcacoqc+osKaRa2p71cC3V6u2VvcHRV7HDFGJU7\\noijbdB+WhqET6Txe67rxZCJG9Ez3EOejBJBl2PJPpy7m1Ml4RR+E8YHNzB0lcBzc\\nEoiJKlDfKSO14E2CPDonnUoWBJWjEvJys3tbvKzsRj2fnLilytPFU0gH3cEjCopi\\nzFoWRdaRuNHYCqlBmso1JFDl8h4fMmglxGNKnKRar0WeGyxb4xXBGpI=\\n-----END CERTIFICATE-----\\n", # noqa: E501 - "chain": [ - "-----BEGIN CERTIFICATE-----\\nMIIDJTCCAg2gAwIBAgIUMsSK+4FGCjW6sL/EXMSxColmKw8wDQYJKoZIhvcNAQEL\\nBQAwIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdoYXRldmVyMB4XDTIyMDcyOTIx\\nMTgyN1oXDTIzMDcyOTIxMTgyN1owIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdo\\nYXRldmVyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA55N9DkgFWbJ/\\naqcdQhso7n1kFvt6j/fL1tJBvRubkiFMQJnZFtekfalN6FfRtA3jq+nx8o49e+7t\\nLCKT0xQ+wufXfOnxv6/if6HMhHTiCNPOCeztUgQ2+dfNwRhYYgB1P93wkUVjwudK\\n13qHTTZ6NtEF6EzOqhOCe6zxq6wrr422+ZqCvcggeQ5tW9xSd/8O1vNID/0MTKpy\\nET3drDtBfHmiUEIBR3T3tcy6QsIe4Rz/2sDinAcM3j7sG8uY6drh8jY3PWar9til\\nv2l4qDYSU8Qm5856AB1FVZRLRJkLxZYZNgreShAIYgEd0mcyI2EO/UvKxsIcxsXc\\nd45GhGpKkwIDAQABo1cwVTAfBgNVHQ4EGAQWBBRXBrXKh3p/aFdQjUcT/UcvICBL\\nODAhBgNVHSMEGjAYgBYEFFcGtcqHen9oV1CNRxP9Ry8gIEs4MA8GA1UdEwEB/wQF\\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAGmCEvcoFUrT9e133SHkgF/ZAgzeIziO\\nBjfAdU4fvAVTVfzaPm0yBnGqzcHyacCzbZjKQpaKVgc5e6IaqAQtf6cZJSCiJGhS\\nJYeosWrj3dahLOUAMrXRr8G/Ybcacoqc+osKaRa2p71cC3V6u2VvcHRV7HDFGJU7\\noijbdB+WhqET6Txe67rxZCJG9Ez3EOejBJBl2PJPpy7m1Ml4RR+E8YHNzB0lcBzc\\nEoiJKlDfKSO14E2CPDonnUoWBJWjEvJys3tbvKzsRj2fnLilytPFU0gH3cEjCopi\\nzFoWRdaRuNHYCqlBmso1JFDl8h4fMmglxGNKnKRar0WeGyxb4xXBGpI=\\n-----END CERTIFICATE-----\\n" # noqa: E501, W505 - ], - "certificate_signing_request": "-----BEGIN CERTIFICATE REQUEST-----\nMIICWjCCAUICAQAwFTETMBEGA1UEAwwKYmFuYW5hLmNvbTCCASIwDQYJKoZIhvcN\nAQEBBQADggEPADCCAQoCggEBANWlx9wE6cW7Jkb4DZZDOZoEjk1eDBMJ+8R4pyKp\nFBeHMl1SQSDt6rAWsrfL3KOGiIHqrRY0B5H6c51L8LDuVrJG0bPmyQ6rsBo3gVke\nDSivfSLtGvHtp8lwYnIunF8r858uYmblAR0tdXQNmnQvm+6GERvURQ6sxpgZ7iLC\npPKDoPt+4GKWL10FWf0i82FgxWC2KqRZUtNbgKETQuARLig7etBmCnh20zmynorA\ncY7vrpTPAaeQpGLNqqYvKV9W6yWVY08V+nqARrFrjk3vSioZSu8ZJUdZ4d9++SGl\nbH7A6e77YDkX9i/dQ3Pa/iDtWO3tXS2MvgoxX1iSWlGNOHcCAwEAAaAAMA0GCSqG\nSIb3DQEBCwUAA4IBAQCW1fKcHessy/ZhnIwAtSLznZeZNH8LTVOzkhVd4HA7EJW+\nKVLBx8DnN7L3V2/uPJfHiOg4Rx7fi7LkJPegl3SCqJZ0N5bQS/KvDTCyLG+9E8Y+\n7wqCmWiXaH1devimXZvazilu4IC2dSks2D8DPWHgsOdVks9bme8J3KjdNMQudegc\newWZZ1Dtbd+Rn7cpKU3jURMwm4fRwGxbJ7iT5fkLlPBlyM/yFEik4SmQxFYrZCQg\n0f3v4kBefTh5yclPy5tEH+8G0LMsbbo3dJ5mPKpAShi0QEKDLd7eR1R/712lYTK4\ndi4XaEfqERgy68O4rvb4PGlJeRGS7AmL7Ss8wfAq\n-----END CERTIFICATE REQUEST-----\n", # noqa: E501 - "certificate": "-----BEGIN CERTIFICATE-----\nMIICvDCCAaQCFFPAOD7utDTsgFrm0vS4We18OcnKMA0GCSqGSIb3DQEBCwUAMCAx\nCzAJBgNVBAYTAlVTMREwDwYDVQQDDAh3aGF0ZXZlcjAeFw0yMjA3MjkyMTE5Mzha\nFw0yMzA3MjkyMTE5MzhaMBUxEzARBgNVBAMMCmJhbmFuYS5jb20wggEiMA0GCSqG\nSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDVpcfcBOnFuyZG+A2WQzmaBI5NXgwTCfvE\neKciqRQXhzJdUkEg7eqwFrK3y9yjhoiB6q0WNAeR+nOdS/Cw7layRtGz5skOq7Aa\nN4FZHg0or30i7Rrx7afJcGJyLpxfK/OfLmJm5QEdLXV0DZp0L5vuhhEb1EUOrMaY\nGe4iwqTyg6D7fuBili9dBVn9IvNhYMVgtiqkWVLTW4ChE0LgES4oO3rQZgp4dtM5\nsp6KwHGO766UzwGnkKRizaqmLylfVusllWNPFfp6gEaxa45N70oqGUrvGSVHWeHf\nfvkhpWx+wOnu+2A5F/Yv3UNz2v4g7Vjt7V0tjL4KMV9YklpRjTh3AgMBAAEwDQYJ\nKoZIhvcNAQELBQADggEBAChjRzuba8zjQ7NYBVas89Oy7u++MlS8xWxh++yiUsV6\nWMk3ZemsPtXc1YmXorIQohtxLxzUPm2JhyzFzU/sOLmJQ1E/l+gtZHyRCwsb20fX\nmphuJsMVd7qv/GwEk9PBsk2uDqg4/Wix0Rx5lf95juJP7CPXQJl5FQauf3+LSz0y\nwF/j+4GqvrwsWr9hKOLmPdkyKkR6bHKtzzsxL9PM8GnElk2OpaPMMnzbL/vt2IAt\nxK01ZzPxCQCzVwHo5IJO5NR/fIyFbEPhxzG17QsRDOBR9fl9cOIvDeSO04vyZ+nz\n+kA2c3fNrZFAtpIlOOmFh8Q12rVL4sAjI5mVWnNEgvI=\n-----END CERTIFICATE-----\n", # noqa: E501 - "revoked": True, - } - ] - }, - ], - "properties": { - "certificates": { - "$id": "#/properties/certificates", - "type": "array", - "items": { - "$id": "#/properties/certificates/items", - "type": "object", - "required": ["certificate_signing_request", "certificate", "ca", "chain"], - "properties": { - "certificate_signing_request": { - "$id": "#/properties/certificates/items/certificate_signing_request", - "type": "string", - }, - "certificate": { - "$id": "#/properties/certificates/items/certificate", - "type": "string", - }, - "ca": {"$id": "#/properties/certificates/items/ca", "type": "string"}, - "chain": { - "$id": "#/properties/certificates/items/chain", - "type": "array", - "items": { - "type": "string", - "$id": "#/properties/certificates/items/chain/items", - }, - }, - "revoked": { - "$id": "#/properties/certificates/items/revoked", - "type": "boolean", - }, - }, - "additionalProperties": True, - }, - } - }, - "required": ["certificates"], - "additionalProperties": True, -} - - -logger = logging.getLogger(__name__) - - -class CertificateAvailableEvent(EventBase): - """Charm Event triggered when a TLS certificate is available.""" - - def __init__( - self, - handle: Handle, - certificate: str, - certificate_signing_request: str, - ca: str, - chain: List[str], - ): - super().__init__(handle) - self.certificate = certificate - self.certificate_signing_request = certificate_signing_request - self.ca = ca - self.chain = chain - - def snapshot(self) -> dict: - """Return snapshot.""" - return { - "certificate": self.certificate, - "certificate_signing_request": self.certificate_signing_request, - "ca": self.ca, - "chain": self.chain, - } - - def restore(self, snapshot: dict): - """Restore snapshot.""" - self.certificate = snapshot["certificate"] - self.certificate_signing_request = snapshot["certificate_signing_request"] - self.ca = snapshot["ca"] - self.chain = snapshot["chain"] - - -class CertificateExpiringEvent(EventBase): - """Charm Event triggered when a TLS certificate is almost expired.""" - - def __init__(self, handle: Handle, certificate: str, expiry: str): - """CertificateExpiringEvent. - - Args: - handle (Handle): Juju framework handle - certificate (str): TLS Certificate - expiry (str): Datetime string representing the time at which the certificate - won't be valid anymore. - """ - super().__init__(handle) - self.certificate = certificate - self.expiry = expiry - - def snapshot(self) -> dict: - """Return snapshot.""" - return {"certificate": self.certificate, "expiry": self.expiry} - - def restore(self, snapshot: dict): - """Restore snapshot.""" - self.certificate = snapshot["certificate"] - self.expiry = snapshot["expiry"] - - -class CertificateInvalidatedEvent(EventBase): - """Charm Event triggered when a TLS certificate is invalidated.""" - - def __init__( - self, - handle: Handle, - reason: Literal["expired", "revoked"], - certificate: str, - certificate_signing_request: str, - ca: str, - chain: List[str], - ): - super().__init__(handle) - self.reason = reason - self.certificate_signing_request = certificate_signing_request - self.certificate = certificate - self.ca = ca - self.chain = chain - - def snapshot(self) -> dict: - """Return snapshot.""" - return { - "reason": self.reason, - "certificate_signing_request": self.certificate_signing_request, - "certificate": self.certificate, - "ca": self.ca, - "chain": self.chain, - } - - def restore(self, snapshot: dict): - """Restore snapshot.""" - self.reason = snapshot["reason"] - self.certificate_signing_request = snapshot["certificate_signing_request"] - self.certificate = snapshot["certificate"] - self.ca = snapshot["ca"] - self.chain = snapshot["chain"] - - -class AllCertificatesInvalidatedEvent(EventBase): - """Charm Event triggered when all TLS certificates are invalidated.""" - - def __init__(self, handle: Handle): - super().__init__(handle) - - def snapshot(self) -> dict: - """Return snapshot.""" - return {} - - def restore(self, snapshot: dict): - """Restore snapshot.""" - pass - - -class CertificateCreationRequestEvent(EventBase): - """Charm Event triggered when a TLS certificate is required.""" - - def __init__( - self, - handle: Handle, - certificate_signing_request: str, - relation_id: int, - is_ca: bool = False, - ): - super().__init__(handle) - self.certificate_signing_request = certificate_signing_request - self.relation_id = relation_id - self.is_ca = is_ca - - def snapshot(self) -> dict: - """Return snapshot.""" - return { - "certificate_signing_request": self.certificate_signing_request, - "relation_id": self.relation_id, - "is_ca": self.is_ca, - } - - def restore(self, snapshot: dict): - """Restore snapshot.""" - self.certificate_signing_request = snapshot["certificate_signing_request"] - self.relation_id = snapshot["relation_id"] - self.is_ca = snapshot["is_ca"] - - -class CertificateRevocationRequestEvent(EventBase): - """Charm Event triggered when a TLS certificate needs to be revoked.""" - - def __init__( - self, - handle: Handle, - certificate: str, - certificate_signing_request: str, - ca: str, - chain: str, - ): - super().__init__(handle) - self.certificate = certificate - self.certificate_signing_request = certificate_signing_request - self.ca = ca - self.chain = chain - - def snapshot(self) -> dict: - """Return snapshot.""" - return { - "certificate": self.certificate, - "certificate_signing_request": self.certificate_signing_request, - "ca": self.ca, - "chain": self.chain, - } - - def restore(self, snapshot: dict): - """Restore snapshot.""" - self.certificate = snapshot["certificate"] - self.certificate_signing_request = snapshot["certificate_signing_request"] - self.ca = snapshot["ca"] - self.chain = snapshot["chain"] - - -def _load_relation_data(relation_data_content: RelationDataContent) -> dict: - """Load relation data from the relation data bag. - - Json loads all data. - - Args: - relation_data_content: Relation data from the databag - - Returns: - dict: Relation data in dict format. - """ - certificate_data = {} - try: - for key in relation_data_content: - try: - certificate_data[key] = json.loads(relation_data_content[key]) - except (json.decoder.JSONDecodeError, TypeError): - certificate_data[key] = relation_data_content[key] - except ModelError: - pass - return certificate_data - - -def _get_closest_future_time( - expiry_notification_time: datetime, expiry_time: datetime -) -> datetime: - """Return expiry_notification_time if not in the past, otherwise return expiry_time. - - Args: - expiry_notification_time (datetime): Notification time of impending expiration - expiry_time (datetime): Expiration time - - Returns: - datetime: expiry_notification_time if not in the past, expiry_time otherwise - """ - return ( - expiry_notification_time - if datetime.now(timezone.utc) < expiry_notification_time - else expiry_time - ) - - -def _get_certificate_expiry_time(certificate: str) -> Optional[datetime]: - """Extract expiry time from a certificate string. - - Args: - certificate (str): x509 certificate as a string - - Returns: - Optional[datetime]: Expiry datetime or None - """ - try: - certificate_object = x509.load_pem_x509_certificate(data=certificate.encode()) - return certificate_object.not_valid_after_utc - except ValueError: - logger.warning("Could not load certificate.") - return None - - -def generate_ca( - private_key: bytes, - subject: str, - private_key_password: Optional[bytes] = None, - validity: int = 365, - country: str = "US", -) -> bytes: - """Generate a CA Certificate. - - Args: - private_key (bytes): Private key - subject (str): Common Name that can be an IP or a Full Qualified Domain Name (FQDN). - private_key_password (bytes): Private key password - validity (int): Certificate validity time (in days) - country (str): Certificate Issuing country - - Returns: - bytes: CA Certificate. - """ - private_key_object = serialization.load_pem_private_key( - private_key, password=private_key_password - ) - subject_name = x509.Name( - [ - x509.NameAttribute(x509.NameOID.COUNTRY_NAME, country), - x509.NameAttribute(x509.NameOID.COMMON_NAME, subject), - ] - ) - subject_identifier_object = x509.SubjectKeyIdentifier.from_public_key( - private_key_object.public_key() # type: ignore[arg-type] - ) - subject_identifier = key_identifier = subject_identifier_object.public_bytes() - key_usage = x509.KeyUsage( - digital_signature=True, - key_encipherment=True, - key_cert_sign=True, - key_agreement=False, - content_commitment=False, - data_encipherment=False, - crl_sign=False, - encipher_only=False, - decipher_only=False, - ) - cert = ( - x509.CertificateBuilder() - .subject_name(subject_name) - .issuer_name(subject_name) - .public_key(private_key_object.public_key()) # type: ignore[arg-type] - .serial_number(x509.random_serial_number()) - .not_valid_before(datetime.now(timezone.utc)) - .not_valid_after(datetime.now(timezone.utc) + timedelta(days=validity)) - .add_extension(x509.SubjectKeyIdentifier(digest=subject_identifier), critical=False) - .add_extension( - x509.AuthorityKeyIdentifier( - key_identifier=key_identifier, - authority_cert_issuer=None, - authority_cert_serial_number=None, - ), - critical=False, - ) - .add_extension(key_usage, critical=True) - .add_extension( - x509.BasicConstraints(ca=True, path_length=None), - critical=True, - ) - .sign(private_key_object, hashes.SHA256()) # type: ignore[arg-type] - ) - return cert.public_bytes(serialization.Encoding.PEM) - - -def get_certificate_extensions( - authority_key_identifier: bytes, - csr: x509.CertificateSigningRequest, - alt_names: Optional[List[str]], - is_ca: bool, -) -> List[x509.Extension]: - """Generate a list of certificate extensions from a CSR and other known information. - - Args: - authority_key_identifier (bytes): Authority key identifier - csr (x509.CertificateSigningRequest): CSR - alt_names (list): List of alt names to put on cert - prefer putting SANs in CSR - is_ca (bool): Whether the certificate is a CA certificate - - Returns: - List[x509.Extension]: List of extensions - """ - cert_extensions_list: List[x509.Extension] = [ - x509.Extension( - oid=ExtensionOID.AUTHORITY_KEY_IDENTIFIER, - value=x509.AuthorityKeyIdentifier( - key_identifier=authority_key_identifier, - authority_cert_issuer=None, - authority_cert_serial_number=None, - ), - critical=False, - ), - x509.Extension( - oid=ExtensionOID.SUBJECT_KEY_IDENTIFIER, - value=x509.SubjectKeyIdentifier.from_public_key(csr.public_key()), - critical=False, - ), - x509.Extension( - oid=ExtensionOID.BASIC_CONSTRAINTS, - critical=True, - value=x509.BasicConstraints(ca=is_ca, path_length=None), - ), - ] - - sans: List[x509.GeneralName] = [] - san_alt_names = [x509.DNSName(name) for name in alt_names] if alt_names else [] - sans.extend(san_alt_names) - try: - loaded_san_ext = csr.extensions.get_extension_for_class(x509.SubjectAlternativeName) - sans.extend( - [x509.DNSName(name) for name in loaded_san_ext.value.get_values_for_type(x509.DNSName)] - ) - sans.extend( - [x509.IPAddress(ip) for ip in loaded_san_ext.value.get_values_for_type(x509.IPAddress)] - ) - sans.extend( - [ - x509.RegisteredID(oid) - for oid in loaded_san_ext.value.get_values_for_type(x509.RegisteredID) - ] - ) - except x509.ExtensionNotFound: - pass - - if sans: - cert_extensions_list.append( - x509.Extension( - oid=ExtensionOID.SUBJECT_ALTERNATIVE_NAME, - critical=False, - value=x509.SubjectAlternativeName(sans), - ) - ) - - if is_ca: - cert_extensions_list.append( - x509.Extension( - ExtensionOID.KEY_USAGE, - critical=True, - value=x509.KeyUsage( - digital_signature=False, - content_commitment=False, - key_encipherment=False, - data_encipherment=False, - key_agreement=False, - key_cert_sign=True, - crl_sign=True, - encipher_only=False, - decipher_only=False, - ), - ) - ) - - existing_oids = {ext.oid for ext in cert_extensions_list} - for extension in csr.extensions: - if extension.oid == ExtensionOID.SUBJECT_ALTERNATIVE_NAME: - continue - if extension.oid in existing_oids: - logger.warning("Extension %s is managed by the TLS provider, ignoring.", extension.oid) - continue - cert_extensions_list.append(extension) - - return cert_extensions_list - - -def generate_certificate( - csr: bytes, - ca: bytes, - ca_key: bytes, - ca_key_password: Optional[bytes] = None, - validity: int = 365, - alt_names: Optional[List[str]] = None, - is_ca: bool = False, -) -> bytes: - """Generate a TLS certificate based on a CSR. - - Args: - csr (bytes): CSR - ca (bytes): CA Certificate - ca_key (bytes): CA private key - ca_key_password: CA private key password - validity (int): Certificate validity (in days) - alt_names (list): List of alt names to put on cert - prefer putting SANs in CSR - is_ca (bool): Whether the certificate is a CA certificate - - Returns: - bytes: Certificate - """ - csr_object = x509.load_pem_x509_csr(csr) - subject = csr_object.subject - ca_pem = x509.load_pem_x509_certificate(ca) - issuer = ca_pem.issuer - private_key = serialization.load_pem_private_key(ca_key, password=ca_key_password) - - certificate_builder = ( - x509.CertificateBuilder() - .subject_name(subject) - .issuer_name(issuer) - .public_key(csr_object.public_key()) - .serial_number(x509.random_serial_number()) - .not_valid_before(datetime.now(timezone.utc)) - .not_valid_after(datetime.now(timezone.utc) + timedelta(days=validity)) - ) - extensions = get_certificate_extensions( - authority_key_identifier=ca_pem.extensions.get_extension_for_class( - x509.SubjectKeyIdentifier - ).value.key_identifier, - csr=csr_object, - alt_names=alt_names, - is_ca=is_ca, - ) - for extension in extensions: - try: - certificate_builder = certificate_builder.add_extension( - extval=extension.value, - critical=extension.critical, - ) - except ValueError as e: - logger.warning("Failed to add extension %s: %s", extension.oid, e) - - cert = certificate_builder.sign(private_key, hashes.SHA256()) # type: ignore[arg-type] - return cert.public_bytes(serialization.Encoding.PEM) - - -def generate_pfx_package( - certificate: bytes, - private_key: bytes, - package_password: str, - private_key_password: Optional[bytes] = None, -) -> bytes: - """Generate a PFX package to contain the TLS certificate and private key. - - Args: - certificate (bytes): TLS certificate - private_key (bytes): Private key - package_password (str): Password to open the PFX package - private_key_password (bytes): Private key password - - Returns: - bytes: - """ - private_key_object = serialization.load_pem_private_key( - private_key, password=private_key_password - ) - certificate_object = x509.load_pem_x509_certificate(certificate) - name = certificate_object.subject.rfc4514_string() - pfx_bytes = pkcs12.serialize_key_and_certificates( - name=name.encode(), - cert=certificate_object, - key=private_key_object, # type: ignore[arg-type] - cas=None, - encryption_algorithm=serialization.BestAvailableEncryption(package_password.encode()), - ) - return pfx_bytes - - -def generate_private_key( - password: Optional[bytes] = None, - key_size: int = 2048, - public_exponent: int = 65537, -) -> bytes: - """Generate a private key. - - Args: - password (bytes): Password for decrypting the private key - key_size (int): Key size in bytes - public_exponent: Public exponent. - - Returns: - bytes: Private Key - """ - private_key = rsa.generate_private_key( - public_exponent=public_exponent, - key_size=key_size, - ) - key_bytes = private_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.TraditionalOpenSSL, - encryption_algorithm=( - serialization.BestAvailableEncryption(password) - if password - else serialization.NoEncryption() - ), - ) - return key_bytes - - -def generate_csr( # noqa: C901 - private_key: bytes, - subject: str, - add_unique_id_to_subject_name: bool = True, - organization: Optional[str] = None, - email_address: Optional[str] = None, - country_name: Optional[str] = None, - private_key_password: Optional[bytes] = None, - sans: Optional[List[str]] = None, - sans_oid: Optional[List[str]] = None, - sans_ip: Optional[List[str]] = None, - sans_dns: Optional[List[str]] = None, - additional_critical_extensions: Optional[List] = None, -) -> bytes: - """Generate a CSR using private key and subject. - - Args: - private_key (bytes): Private key - subject (str): CSR Common Name that can be an IP or a Full Qualified Domain Name (FQDN). - add_unique_id_to_subject_name (bool): Whether a unique ID must be added to the CSR's - subject name. Always leave to "True" when the CSR is used to request certificates - using the tls-certificates relation. - organization (str): Name of organization. - email_address (str): Email address. - country_name (str): Country Name. - private_key_password (bytes): Private key password - sans (list): Use sans_dns - this will be deprecated in a future release - List of DNS subject alternative names (keeping it for now for backward compatibility) - sans_oid (list): List of registered ID SANs - sans_dns (list): List of DNS subject alternative names (similar to the arg: sans) - sans_ip (list): List of IP subject alternative names - additional_critical_extensions (list): List of critical additional extension objects. - Object must be a x509 ExtensionType. - - Returns: - bytes: CSR - """ - signing_key = serialization.load_pem_private_key(private_key, password=private_key_password) - subject_name = [x509.NameAttribute(x509.NameOID.COMMON_NAME, subject)] - if add_unique_id_to_subject_name: - unique_identifier = uuid.uuid4() - subject_name.append( - x509.NameAttribute(x509.NameOID.X500_UNIQUE_IDENTIFIER, str(unique_identifier)) - ) - if organization: - subject_name.append(x509.NameAttribute(x509.NameOID.ORGANIZATION_NAME, organization)) - if email_address: - subject_name.append(x509.NameAttribute(x509.NameOID.EMAIL_ADDRESS, email_address)) - if country_name: - subject_name.append(x509.NameAttribute(x509.NameOID.COUNTRY_NAME, country_name)) - csr = x509.CertificateSigningRequestBuilder(subject_name=x509.Name(subject_name)) - - _sans: List[x509.GeneralName] = [] - if sans_oid: - _sans.extend([x509.RegisteredID(x509.ObjectIdentifier(san)) for san in sans_oid]) - if sans_ip: - _sans.extend([x509.IPAddress(IPv4Address(san)) for san in sans_ip]) - if sans: - _sans.extend([x509.DNSName(san) for san in sans]) - if sans_dns: - _sans.extend([x509.DNSName(san) for san in sans_dns]) - if _sans: - csr = csr.add_extension(x509.SubjectAlternativeName(set(_sans)), critical=False) - - if additional_critical_extensions: - for extension in additional_critical_extensions: - csr = csr.add_extension(extension, critical=True) - - signed_certificate = csr.sign(signing_key, hashes.SHA256()) # type: ignore[arg-type] - return signed_certificate.public_bytes(serialization.Encoding.PEM) - - -def csr_matches_certificate(csr: str, cert: str) -> bool: - """Check if a CSR matches a certificate. - - Args: - csr (str): Certificate Signing Request as a string - cert (str): Certificate as a string - Returns: - bool: True/False depending on whether the CSR matches the certificate. - """ - try: - csr_object = x509.load_pem_x509_csr(csr.encode("utf-8")) - cert_object = x509.load_pem_x509_certificate(cert.encode("utf-8")) - - if csr_object.public_key().public_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PublicFormat.SubjectPublicKeyInfo, - ) != cert_object.public_key().public_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PublicFormat.SubjectPublicKeyInfo, - ): - return False - if ( - csr_object.public_key().public_numbers().n # type: ignore[union-attr] - != cert_object.public_key().public_numbers().n # type: ignore[union-attr] - ): - return False - except ValueError: - logger.warning("Could not load certificate or CSR.") - return False - return True - - -class CertificatesProviderCharmEvents(CharmEvents): - """List of events that the TLS Certificates provider charm can leverage.""" - - certificate_creation_request = EventSource(CertificateCreationRequestEvent) - certificate_revocation_request = EventSource(CertificateRevocationRequestEvent) - - -class CertificatesRequirerCharmEvents(CharmEvents): - """List of events that the TLS Certificates requirer charm can leverage.""" - - certificate_available = EventSource(CertificateAvailableEvent) - certificate_expiring = EventSource(CertificateExpiringEvent) - certificate_invalidated = EventSource(CertificateInvalidatedEvent) - all_certificates_invalidated = EventSource(AllCertificatesInvalidatedEvent) - - -class TLSCertificatesProvidesV2(Object): - """TLS certificates provider class to be instantiated by TLS certificates providers.""" - - on = CertificatesProviderCharmEvents() # type: ignore[reportAssignmentType] - - def __init__(self, charm: CharmBase, relationship_name: str): - super().__init__(charm, relationship_name) - self.framework.observe( - charm.on[relationship_name].relation_changed, self._on_relation_changed - ) - self.charm = charm - self.relationship_name = relationship_name - - def _load_app_relation_data(self, relation: Relation) -> dict: - """Load relation data from the application relation data bag. - - Json loads all data. - - Args: - relation: Relation data from the application databag - - Returns: - dict: Relation data in dict format. - """ - # If unit is not leader, it does not try to reach relation data. - if not self.model.unit.is_leader(): - return {} - return _load_relation_data(relation.data[self.charm.app]) - - def _add_certificate( - self, - relation_id: int, - certificate: str, - certificate_signing_request: str, - ca: str, - chain: List[str], - ) -> None: - """Add certificate to relation data. - - Args: - relation_id (int): Relation id - certificate (str): Certificate - certificate_signing_request (str): Certificate Signing Request - ca (str): CA Certificate - chain (list): CA Chain - - Returns: - None - """ - relation = self.model.get_relation( - relation_name=self.relationship_name, relation_id=relation_id - ) - if not relation: - raise RuntimeError( - f"Relation {self.relationship_name} does not exist - " - f"The certificate request can't be completed" - ) - new_certificate = { - "certificate": certificate, - "certificate_signing_request": certificate_signing_request, - "ca": ca, - "chain": chain, - } - provider_relation_data = self._load_app_relation_data(relation) - provider_certificates = provider_relation_data.get("certificates", []) - certificates = copy.deepcopy(provider_certificates) - if new_certificate in certificates: - logger.info("Certificate already in relation data - Doing nothing") - return - certificates.append(new_certificate) - relation.data[self.model.app]["certificates"] = json.dumps(certificates) - - def _remove_certificate( - self, - relation_id: int, - certificate: Optional[str] = None, - certificate_signing_request: Optional[str] = None, - ) -> None: - """Remove certificate from a given relation based on user provided certificate or csr. - - Args: - relation_id (int): Relation id - certificate (str): Certificate (optional) - certificate_signing_request: Certificate signing request (optional) - - Returns: - None - """ - relation = self.model.get_relation( - relation_name=self.relationship_name, - relation_id=relation_id, - ) - if not relation: - raise RuntimeError( - f"Relation {self.relationship_name} with relation id {relation_id} does not exist" - ) - provider_relation_data = self._load_app_relation_data(relation) - provider_certificates = provider_relation_data.get("certificates", []) - certificates = copy.deepcopy(provider_certificates) - for certificate_dict in certificates: - if certificate and certificate_dict["certificate"] == certificate: - certificates.remove(certificate_dict) - if ( - certificate_signing_request - and certificate_dict["certificate_signing_request"] == certificate_signing_request - ): - certificates.remove(certificate_dict) - relation.data[self.model.app]["certificates"] = json.dumps(certificates) - - @staticmethod - def _relation_data_is_valid(certificates_data: dict) -> bool: - """Use JSON schema validator to validate relation data content. - - Args: - certificates_data (dict): Certificate data dictionary as retrieved from relation data. - - Returns: - bool: True/False depending on whether the relation data follows the json schema. - """ - try: - validate(instance=certificates_data, schema=REQUIRER_JSON_SCHEMA) - return True - except exceptions.ValidationError: - return False - - def revoke_all_certificates(self) -> None: - """Revoke all certificates of this provider. - - This method is meant to be used when the Root CA has changed. - """ - for relation in self.model.relations[self.relationship_name]: - provider_relation_data = self._load_app_relation_data(relation) - provider_certificates = copy.deepcopy(provider_relation_data.get("certificates", [])) - for certificate in provider_certificates: - certificate["revoked"] = True - relation.data[self.model.app]["certificates"] = json.dumps(provider_certificates) - - def set_relation_certificate( - self, - certificate: str, - certificate_signing_request: str, - ca: str, - chain: List[str], - relation_id: int, - ) -> None: - """Add certificates to relation data. - - Args: - certificate (str): Certificate - certificate_signing_request (str): Certificate signing request - ca (str): CA Certificate - chain (list): CA Chain - relation_id (int): Juju relation ID - - Returns: - None - """ - if not self.model.unit.is_leader(): - return - certificates_relation = self.model.get_relation( - relation_name=self.relationship_name, relation_id=relation_id - ) - if not certificates_relation: - raise RuntimeError(f"Relation {self.relationship_name} does not exist") - self._remove_certificate( - certificate_signing_request=certificate_signing_request.strip(), - relation_id=relation_id, - ) - self._add_certificate( - relation_id=relation_id, - certificate=certificate.strip(), - certificate_signing_request=certificate_signing_request.strip(), - ca=ca.strip(), - chain=[cert.strip() for cert in chain], - ) - - def remove_certificate(self, certificate: str) -> None: - """Remove a given certificate from relation data. - - Args: - certificate (str): TLS Certificate - - Returns: - None - """ - certificates_relation = self.model.relations[self.relationship_name] - if not certificates_relation: - raise RuntimeError(f"Relation {self.relationship_name} does not exist") - for certificate_relation in certificates_relation: - self._remove_certificate(certificate=certificate, relation_id=certificate_relation.id) - - def get_issued_certificates( - self, relation_id: Optional[int] = None - ) -> Dict[str, List[Dict[str, str]]]: - """Return a dictionary of issued certificates. - - It returns certificates from all relations if relation_id is not specified. - Certificates are returned per application name and CSR. - - Returns: - dict: Certificates per application name. - """ - certificates: Dict[str, List[Dict[str, str]]] = {} - relations = ( - [ - relation - for relation in self.model.relations[self.relationship_name] - if relation.id == relation_id - ] - if relation_id is not None - else self.model.relations.get(self.relationship_name, []) - ) - for relation in relations: - provider_relation_data = self._load_app_relation_data(relation) - provider_certificates = provider_relation_data.get("certificates", []) - - certificates[relation.app.name] = [] # type: ignore[union-attr] - for certificate in provider_certificates: - if not certificate.get("revoked", False): - certificates[relation.app.name].append( # type: ignore[union-attr] - { - "csr": certificate["certificate_signing_request"], - "certificate": certificate["certificate"], - } - ) - - return certificates - - def _on_relation_changed(self, event: RelationChangedEvent) -> None: - """Handle relation changed event. - - Looks at the relation data and either emits: - - certificate request event: If the unit relation data contains a CSR for which - a certificate does not exist in the provider relation data. - - certificate revocation event: If the provider relation data contains a CSR for which - a csr does not exist in the requirer relation data. - - Args: - event: Juju event - - Returns: - None - """ - if event.unit is None: - logger.error("Relation_changed event does not have a unit.") - return - if not self.model.unit.is_leader(): - return - requirer_relation_data = _load_relation_data(event.relation.data[event.unit]) - provider_relation_data = self._load_app_relation_data(event.relation) - if not self._relation_data_is_valid(requirer_relation_data): - logger.debug("Relation data did not pass JSON Schema validation") - return - provider_certificates = provider_relation_data.get("certificates", []) - requirer_csrs = requirer_relation_data.get("certificate_signing_requests", []) - provider_csrs = [ - certificate_creation_request["certificate_signing_request"] - for certificate_creation_request in provider_certificates - ] - requirer_unit_certificate_requests = [ - { - "csr": certificate_creation_request["certificate_signing_request"], - "is_ca": certificate_creation_request.get("ca", False), - } - for certificate_creation_request in requirer_csrs - ] - for certificate_request in requirer_unit_certificate_requests: - if certificate_request["csr"] not in provider_csrs: - self.on.certificate_creation_request.emit( - certificate_signing_request=certificate_request["csr"], - relation_id=event.relation.id, - is_ca=certificate_request["is_ca"], - ) - self._revoke_certificates_for_which_no_csr_exists(relation_id=event.relation.id) - - def _revoke_certificates_for_which_no_csr_exists(self, relation_id: int) -> None: - """Revoke certificates for which no unit has a CSR. - - Goes through all generated certificates and compare against the list of CSRs for all units - of a given relationship. - - Args: - relation_id (int): Relation id - - Returns: - None - """ - certificates_relation = self.model.get_relation( - relation_name=self.relationship_name, relation_id=relation_id - ) - if not certificates_relation: - raise RuntimeError(f"Relation {self.relationship_name} does not exist") - provider_relation_data = self._load_app_relation_data(certificates_relation) - list_of_csrs: List[str] = [] - for unit in certificates_relation.units: - requirer_relation_data = _load_relation_data(certificates_relation.data[unit]) - requirer_csrs = requirer_relation_data.get("certificate_signing_requests", []) - list_of_csrs.extend(csr["certificate_signing_request"] for csr in requirer_csrs) - provider_certificates = provider_relation_data.get("certificates", []) - for certificate in provider_certificates: - if certificate["certificate_signing_request"] not in list_of_csrs: - self.on.certificate_revocation_request.emit( - certificate=certificate["certificate"], - certificate_signing_request=certificate["certificate_signing_request"], - ca=certificate["ca"], - chain=certificate["chain"], - ) - self.remove_certificate(certificate=certificate["certificate"]) - - def get_outstanding_certificate_requests( - self, relation_id: Optional[int] = None - ) -> List[Dict[str, Union[int, str, List[Dict[str, str]]]]]: - """Return CSR's for which no certificate has been issued. - - Example return: [ - { - "relation_id": 0, - "application_name": "tls-certificates-requirer", - "unit_name": "tls-certificates-requirer/0", - "unit_csrs": [ - { - "certificate_signing_request": "-----BEGIN CERTIFICATE REQUEST-----...", - "is_ca": false - } - ] - } - ] - - Args: - relation_id (int): Relation id - - Returns: - list: List of dictionaries that contain the unit's csrs - that don't have a certificate issued. - """ - all_unit_csr_mappings = copy.deepcopy(self.get_requirer_csrs(relation_id=relation_id)) - filtered_all_unit_csr_mappings: List[Dict[str, Union[int, str, List[Dict[str, str]]]]] = [] - for unit_csr_mapping in all_unit_csr_mappings: - csrs_without_certs = [] - for csr in unit_csr_mapping["unit_csrs"]: # type: ignore[union-attr] - if not self.certificate_issued_for_csr( - app_name=unit_csr_mapping["application_name"], # type: ignore[arg-type] - csr=csr["certificate_signing_request"], # type: ignore[index] - relation_id=relation_id, - ): - csrs_without_certs.append(csr) - if csrs_without_certs: - unit_csr_mapping["unit_csrs"] = csrs_without_certs # type: ignore[assignment] - filtered_all_unit_csr_mappings.append(unit_csr_mapping) - return filtered_all_unit_csr_mappings - - def get_requirer_csrs( - self, relation_id: Optional[int] = None - ) -> List[Dict[str, Union[int, str, List[Dict[str, str]]]]]: - """Return a list of requirers' CSRs grouped by unit. - - It returns CSRs from all relations if relation_id is not specified. - CSRs are returned per relation id, application name and unit name. - - Returns: - list: List of dictionaries that contain the unit's csrs - with the following information - relation_id, application_name and unit_name. - """ - unit_csr_mappings: List[Dict[str, Union[int, str, List[Dict[str, str]]]]] = [] - - relations = ( - [ - relation - for relation in self.model.relations[self.relationship_name] - if relation.id == relation_id - ] - if relation_id is not None - else self.model.relations.get(self.relationship_name, []) - ) - - for relation in relations: - for unit in relation.units: - requirer_relation_data = _load_relation_data(relation.data[unit]) - unit_csrs_list = requirer_relation_data.get("certificate_signing_requests", []) - unit_csr_mappings.append( - { - "relation_id": relation.id, - "application_name": relation.app.name, # type: ignore[union-attr] - "unit_name": unit.name, - "unit_csrs": unit_csrs_list, - } - ) - return unit_csr_mappings - - def certificate_issued_for_csr( - self, app_name: str, csr: str, relation_id: Optional[int] - ) -> bool: - """Check whether a certificate has been issued for a given CSR. - - Args: - app_name (str): Application name that the CSR belongs to. - csr (str): Certificate Signing Request. - relation_id (Optional[int]): Relation ID - Returns: - bool: True/False depending on whether a certificate has been issued for the given CSR. - """ - issued_certificates_per_csr = self.get_issued_certificates(relation_id=relation_id)[ - app_name - ] - for issued_pair in issued_certificates_per_csr: - if "csr" in issued_pair and issued_pair["csr"] == csr: - return csr_matches_certificate(csr, issued_pair["certificate"]) - return False - - -class TLSCertificatesRequiresV2(Object): - """TLS certificates requirer class to be instantiated by TLS certificates requirers.""" - - on = CertificatesRequirerCharmEvents() # type: ignore[reportAssignmentType] - - def __init__( - self, - charm: CharmBase, - relationship_name: str, - expiry_notification_time: int = 168, - ): - """Generate/use private key and observes relation changed event. - - Args: - charm: Charm object - relationship_name: Juju relation name - expiry_notification_time (int): Time difference between now and expiry (in hours). - Used to trigger the CertificateExpiring event. Default: 7 days. - """ - super().__init__(charm, relationship_name) - self.relationship_name = relationship_name - self.charm = charm - self.expiry_notification_time = expiry_notification_time - self.framework.observe( - charm.on[relationship_name].relation_changed, self._on_relation_changed - ) - self.framework.observe( - charm.on[relationship_name].relation_broken, self._on_relation_broken - ) - if JujuVersion.from_environ().has_secrets: - self.framework.observe(charm.on.secret_expired, self._on_secret_expired) - else: - self.framework.observe(charm.on.update_status, self._on_update_status) - - @property - def _requirer_csrs(self) -> List[Dict[str, Union[bool, str]]]: - """Return list of requirer's CSRs from relation unit data. - - Example: - [ - { - "certificate_signing_request": "-----BEGIN CERTIFICATE REQUEST-----...", - "ca": false - } - ] - """ - relation = self.model.get_relation(self.relationship_name) - if not relation: - raise RuntimeError(f"Relation {self.relationship_name} does not exist") - requirer_relation_data = _load_relation_data(relation.data[self.model.unit]) - return requirer_relation_data.get("certificate_signing_requests", []) - - @property - def _provider_certificates(self) -> List[Dict[str, str]]: - """Return list of certificates from the provider's relation data.""" - relation = self.model.get_relation(self.relationship_name) - if not relation: - logger.debug("No relation: %s", self.relationship_name) - return [] - if not relation.app: - logger.debug("No remote app in relation: %s", self.relationship_name) - return [] - provider_relation_data = _load_relation_data(relation.data[relation.app]) - if not self._relation_data_is_valid(provider_relation_data): - logger.warning("Provider relation data did not pass JSON Schema validation") - return [] - return provider_relation_data.get("certificates", []) - - def _add_requirer_csr(self, csr: str, is_ca: bool) -> None: - """Add CSR to relation data. - - Args: - csr (str): Certificate Signing Request - is_ca (bool): Whether the certificate is a CA certificate - - Returns: - None - """ - relation = self.model.get_relation(self.relationship_name) - if not relation: - raise RuntimeError( - f"Relation {self.relationship_name} does not exist - " - f"The certificate request can't be completed" - ) - new_csr_dict: Dict[str, Union[bool, str]] = { - "certificate_signing_request": csr, - "ca": is_ca, - } - if new_csr_dict in self._requirer_csrs: - logger.info("CSR already in relation data - Doing nothing") - return - requirer_csrs = copy.deepcopy(self._requirer_csrs) - requirer_csrs.append(new_csr_dict) - relation.data[self.model.unit]["certificate_signing_requests"] = json.dumps(requirer_csrs) - - def _remove_requirer_csr(self, csr: str) -> None: - """Remove CSR from relation data. - - Args: - csr (str): Certificate signing request - - Returns: - None - """ - relation = self.model.get_relation(self.relationship_name) - if not relation: - raise RuntimeError( - f"Relation {self.relationship_name} does not exist - " - f"The certificate request can't be completed" - ) - requirer_csrs = copy.deepcopy(self._requirer_csrs) - if not requirer_csrs: - logger.info("No CSRs in relation data - Doing nothing") - return - for requirer_csr in requirer_csrs: - if requirer_csr["certificate_signing_request"] == csr: - requirer_csrs.remove(requirer_csr) - relation.data[self.model.unit]["certificate_signing_requests"] = json.dumps(requirer_csrs) - - def request_certificate_creation( - self, certificate_signing_request: bytes, is_ca: bool = False - ) -> None: - """Request TLS certificate to provider charm. - - Args: - certificate_signing_request (bytes): Certificate Signing Request - is_ca (bool): Whether the certificate is a CA certificate - - Returns: - None - """ - relation = self.model.get_relation(self.relationship_name) - if not relation: - raise RuntimeError( - f"Relation {self.relationship_name} does not exist - " - f"The certificate request can't be completed" - ) - self._add_requirer_csr(certificate_signing_request.decode().strip(), is_ca=is_ca) - logger.info("Certificate request sent to provider") - - def request_certificate_revocation(self, certificate_signing_request: bytes) -> None: - """Remove CSR from relation data. - - The provider of this relation is then expected to remove certificates associated to this - CSR from the relation data as well and emit a request_certificate_revocation event for the - provider charm to interpret. - - Args: - certificate_signing_request (bytes): Certificate Signing Request - - Returns: - None - """ - self._remove_requirer_csr(certificate_signing_request.decode().strip()) - logger.info("Certificate revocation sent to provider") - - def request_certificate_renewal( - self, old_certificate_signing_request: bytes, new_certificate_signing_request: bytes - ) -> None: - """Renew certificate. - - Removes old CSR from relation data and adds new one. - - Args: - old_certificate_signing_request: Old CSR - new_certificate_signing_request: New CSR - - Returns: - None - """ - try: - self.request_certificate_revocation( - certificate_signing_request=old_certificate_signing_request - ) - except RuntimeError: - logger.warning("Certificate revocation failed.") - self.request_certificate_creation( - certificate_signing_request=new_certificate_signing_request - ) - logger.info("Certificate renewal request completed.") - - def get_assigned_certificates(self) -> List[Dict[str, str]]: - """Get a list of certificates that were assigned to this unit. - - Returns: - List of certificates. For example: - [ - { - "ca": "-----BEGIN CERTIFICATE-----...", - "chain": [ - "-----BEGIN CERTIFICATE-----..." - ], - "certificate": "-----BEGIN CERTIFICATE-----...", - "certificate_signing_request": "-----BEGIN CERTIFICATE REQUEST-----...", - } - ] - """ - final_list = [] - for csr in self.get_certificate_signing_requests(fulfilled_only=True): - assert isinstance(csr["certificate_signing_request"], str) - if cert := self._find_certificate_in_relation_data(csr["certificate_signing_request"]): - final_list.append(cert) - return final_list - - def get_expiring_certificates(self) -> List[Dict[str, str]]: - """Get a list of certificates that were assigned to this unit that are expiring or expired. - - Returns: - List of certificates. For example: - [ - { - "ca": "-----BEGIN CERTIFICATE-----...", - "chain": [ - "-----BEGIN CERTIFICATE-----..." - ], - "certificate": "-----BEGIN CERTIFICATE-----...", - "certificate_signing_request": "-----BEGIN CERTIFICATE REQUEST-----...", - } - ] - """ - final_list = [] - for csr in self.get_certificate_signing_requests(fulfilled_only=True): - assert isinstance(csr["certificate_signing_request"], str) - if cert := self._find_certificate_in_relation_data(csr["certificate_signing_request"]): - expiry_time = _get_certificate_expiry_time(cert["certificate"]) - if not expiry_time: - continue - expiry_notification_time = expiry_time - timedelta( - hours=self.expiry_notification_time - ) - if datetime.now(timezone.utc) > expiry_notification_time: - final_list.append(cert) - return final_list - - def get_certificate_signing_requests( - self, - fulfilled_only: bool = False, - unfulfilled_only: bool = False, - ) -> List[Dict[str, Union[bool, str]]]: - """Get the list of CSR's that were sent to the provider. - - You can choose to get only the CSR's that have a certificate assigned or only the CSR's - that don't. - - Args: - fulfilled_only (bool): This option will discard CSRs that don't have certificates yet. - unfulfilled_only (bool): This option will discard CSRs that have certificates signed. - - Returns: - List of CSR dictionaries. For example: - [ - { - "certificate_signing_request": "-----BEGIN CERTIFICATE REQUEST-----...", - "ca": false - } - ] - """ - final_list = [] - for csr in self._requirer_csrs: - assert isinstance(csr["certificate_signing_request"], str) - cert = self._find_certificate_in_relation_data(csr["certificate_signing_request"]) - if (unfulfilled_only and cert) or (fulfilled_only and not cert): - continue - final_list.append(csr) - - return final_list - - @staticmethod - def _relation_data_is_valid(certificates_data: dict) -> bool: - """Check whether relation data is valid based on json schema. - - Args: - certificates_data: Certificate data in dict format. - - Returns: - bool: Whether relation data is valid. - """ - try: - validate(instance=certificates_data, schema=PROVIDER_JSON_SCHEMA) - return True - except exceptions.ValidationError: - return False - - def _on_relation_changed(self, event: RelationChangedEvent) -> None: - """Handle relation changed event. - - Goes through all providers certificates that match a requested CSR. - - If the provider certificate is revoked, emit a CertificateInvalidateEvent, - otherwise emit a CertificateAvailableEvent. - - When Juju secrets are available, remove the secret for revoked certificate, - or add a secret with the correct expiry time for new certificates. - - - Args: - event: Juju event - - Returns: - None - """ - requirer_csrs = [ - certificate_creation_request["certificate_signing_request"] - for certificate_creation_request in self._requirer_csrs - ] - for certificate in self._provider_certificates: - if certificate["certificate_signing_request"] in requirer_csrs: - if certificate.get("revoked", False): - if JujuVersion.from_environ().has_secrets: - with suppress(SecretNotFoundError): - secret = self.model.get_secret( - label=f"{LIBID}-{certificate['certificate_signing_request']}" - ) - secret.remove_all_revisions() - self.on.certificate_invalidated.emit( - reason="revoked", - certificate=certificate["certificate"], - certificate_signing_request=certificate["certificate_signing_request"], - ca=certificate["ca"], - chain=certificate["chain"], - ) - else: - if JujuVersion.from_environ().has_secrets: - try: - secret = self.model.get_secret( - label=f"{LIBID}-{certificate['certificate_signing_request']}" - ) - secret.set_content({"certificate": certificate["certificate"]}) - secret.set_info( - expire=self._get_next_secret_expiry_time( - certificate["certificate"] - ), - ) - except SecretNotFoundError: - secret = self.charm.unit.add_secret( - {"certificate": certificate["certificate"]}, - label=f"{LIBID}-{certificate['certificate_signing_request']}", - expire=self._get_next_secret_expiry_time( - certificate["certificate"] - ), - ) - self.on.certificate_available.emit( - certificate_signing_request=certificate["certificate_signing_request"], - certificate=certificate["certificate"], - ca=certificate["ca"], - chain=certificate["chain"], - ) - - def _get_next_secret_expiry_time(self, certificate: str) -> Optional[datetime]: - """Return the expiry time or expiry notification time. - - Extracts the expiry time from the provided certificate, calculates the - expiry notification time and return the closest of the two, that is in - the future. - - Args: - certificate: x509 certificate - - Returns: - Optional[datetime]: None if the certificate expiry time cannot be read, - next expiry time otherwise. - """ - expiry_time = _get_certificate_expiry_time(certificate) - if not expiry_time: - return None - expiry_notification_time = expiry_time - timedelta(hours=self.expiry_notification_time) - return _get_closest_future_time(expiry_notification_time, expiry_time) - - def _on_relation_broken(self, event: RelationBrokenEvent) -> None: - """Handle relation broken event. - - Emitting `all_certificates_invalidated` from `relation-broken` rather - than `relation-departed` since certs are stored in app data. - - Args: - event: Juju event - - Returns: - None - """ - self.on.all_certificates_invalidated.emit() - - def _on_secret_expired(self, event: SecretExpiredEvent) -> None: - """Handle secret expired event. - - Loads the certificate from the secret, and will emit 1 of 2 - events. - - If the certificate is not yet expired, emits CertificateExpiringEvent - and updates the expiry time of the secret to the exact expiry time on - the certificate. - - If the certificate is expired, emits CertificateInvalidedEvent and - deletes the secret. - - Args: - event (SecretExpiredEvent): Juju event - """ - if not event.secret.label or not event.secret.label.startswith(f"{LIBID}-"): - return - csr = event.secret.label[len(f"{LIBID}-") :] - certificate_dict = self._find_certificate_in_relation_data(csr) - if not certificate_dict: - # A secret expired but we did not find matching certificate. Cleaning up - event.secret.remove_all_revisions() - return - - expiry_time = _get_certificate_expiry_time(certificate_dict["certificate"]) - if not expiry_time: - # A secret expired but matching certificate is invalid. Cleaning up - event.secret.remove_all_revisions() - return - - if datetime.now(timezone.utc) < expiry_time: - logger.warning("Certificate almost expired") - self.on.certificate_expiring.emit( - certificate=certificate_dict["certificate"], - expiry=expiry_time.isoformat(), - ) - event.secret.set_info( - expire=_get_certificate_expiry_time(certificate_dict["certificate"]), - ) - else: - logger.warning("Certificate is expired") - self.on.certificate_invalidated.emit( - reason="expired", - certificate=certificate_dict["certificate"], - certificate_signing_request=certificate_dict["certificate_signing_request"], - ca=certificate_dict["ca"], - chain=certificate_dict["chain"], - ) - self.request_certificate_revocation(certificate_dict["certificate"].encode()) - event.secret.remove_all_revisions() - - def _find_certificate_in_relation_data(self, csr: str) -> Optional[Dict[str, Any]]: - """Return the certificate that match the given CSR.""" - for certificate_dict in self._provider_certificates: - if certificate_dict["certificate_signing_request"] != csr: - continue - return certificate_dict - return None - - def _on_update_status(self, event: UpdateStatusEvent) -> None: - """Handle update status event. - - Goes through each certificate in the "certificates" relation and checks their expiry date. - If they are close to expire (<7 days), emits a CertificateExpiringEvent event and if - they are expired, emits a CertificateExpiredEvent. - - Args: - event (UpdateStatusEvent): Juju event - - Returns: - None - """ - for certificate_dict in self._provider_certificates: - expiry_time = _get_certificate_expiry_time(certificate_dict["certificate"]) - if not expiry_time: - continue - time_difference = expiry_time - datetime.now(timezone.utc) - if time_difference.total_seconds() < 0: - logger.warning("Certificate is expired") - self.on.certificate_invalidated.emit( - reason="expired", - certificate=certificate_dict["certificate"], - certificate_signing_request=certificate_dict["certificate_signing_request"], - ca=certificate_dict["ca"], - chain=certificate_dict["chain"], - ) - self.request_certificate_revocation(certificate_dict["certificate"].encode()) - continue - if time_difference.total_seconds() < (self.expiry_notification_time * 60 * 60): - logger.warning("Certificate almost expired") - self.on.certificate_expiring.emit( - certificate=certificate_dict["certificate"], - expiry=expiry_time.isoformat(), - ) diff --git a/machines/poetry.lock b/machines/poetry.lock index 8dc07bd8a..feebfc932 100644 --- a/machines/poetry.lock +++ b/machines/poetry.lock @@ -206,7 +206,7 @@ version = "2.0.0" description = "Foreign Function Interface for Python calling C code." optional = false python-versions = ">=3.9" -groups = ["charm-libs", "integration"] +groups = ["main", "charm-libs", "integration"] files = [ {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, @@ -293,7 +293,7 @@ files = [ {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, ] -markers = {charm-libs = "platform_python_implementation != \"PyPy\""} +markers = {main = "platform_python_implementation != \"PyPy\"", charm-libs = "platform_python_implementation != \"PyPy\""} [package.dependencies] pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} @@ -363,6 +363,23 @@ files = [ dunamai = ">=1.23.1" tomlkit = ">=0.13.2" +[[package]] +name = "charmlibs-interfaces-tls-certificates" +version = "1.8.1" +description = "The charmlibs.interfaces.tls_certificates package." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "charmlibs_interfaces_tls_certificates-1.8.1-py3-none-any.whl", hash = "sha256:8e8fe047e02515d76f57a1d019056d72ce8c859c2ffb39a1e379cfc11fc048e6"}, + {file = "charmlibs_interfaces_tls_certificates-1.8.1.tar.gz", hash = "sha256:f2bfabf3a3b4c18034941771733177b30e4742c06d7742d4bb30da6ead953f43"}, +] + +[package.dependencies] +cryptography = ">=43.0.0" +ops = "*" +pydantic = "*" + [[package]] name = "charset-normalizer" version = "3.2.0" @@ -578,7 +595,7 @@ version = "46.0.6" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = "!=3.9.0,!=3.9.1,>=3.8" -groups = ["charm-libs", "integration"] +groups = ["main", "charm-libs", "integration"] files = [ {file = "cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8"}, {file = "cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30"}, @@ -1403,12 +1420,12 @@ version = "2.21" description = "C parser in Python" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -groups = ["charm-libs", "integration"] +groups = ["main", "charm-libs", "integration"] files = [ {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, ] -markers = {charm-libs = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"", integration = "implementation_name != \"PyPy\""} +markers = {main = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"", charm-libs = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"", integration = "implementation_name != \"PyPy\""} [[package]] name = "pydantic" diff --git a/machines/pyproject.toml b/machines/pyproject.toml index d19d27614..d7db3fe73 100644 --- a/machines/pyproject.toml +++ b/machines/pyproject.toml @@ -5,6 +5,7 @@ main = [ "boto3~=1.28", "charm-refresh~=3.1.1", + "charmlibs-interfaces-tls-certificates~=1.0", "jinja2~=3.1", "object-storage-charmlib~=1.0.0", "ops[tracing]~=3.5", From 11c1e3db97889f1c0efac22edd4592f0416766ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sinclert=20P=C3=A9rez?= Date: Wed, 15 Apr 2026 17:32:05 +0200 Subject: [PATCH 7/9] [VM] Remove TLS charmlib --- machines/actions.yaml | 10 -- machines/lib/charms/mysql/v0/tls.py | 259 ---------------------------- 2 files changed, 269 deletions(-) delete mode 100644 machines/lib/charms/mysql/v0/tls.py diff --git a/machines/actions.yaml b/machines/actions.yaml index bfbb88f21..3ed164ffc 100644 --- a/machines/actions.yaml +++ b/machines/actions.yaml @@ -33,16 +33,6 @@ set-password: type: string description: The password will be auto-generated if this option is not specified. -set-tls-private-key: - description: - Set the privates key, which will be used for certificate signing requests (CSR). Run - for each unit separately. - params: - internal-key: - type: string - description: The content of private key for internal communications with - clients. Content will be auto-generated if this option is not specified. - create-backup: description: Create a database backup using xtrabackup. S3 credentials are retrieved from a relation with the S3 integrator charm. diff --git a/machines/lib/charms/mysql/v0/tls.py b/machines/lib/charms/mysql/v0/tls.py deleted file mode 100644 index 2554af39c..000000000 --- a/machines/lib/charms/mysql/v0/tls.py +++ /dev/null @@ -1,259 +0,0 @@ -# Copyright 2023 Canonical Ltd. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Library containing the implementation of the tls certificates relation for mysql charm. - -This library is used by the mysql charm to provide the TLS certificates relation. -It requires the TLS certificates library and the MySQL library. - -""" - -import base64 -import logging -import re -import socket -import typing - -from charms.mysql.v0.mysql import MySQLKillSessionError, MySQLTLSSetupError -from charms.tls_certificates_interface.v2.tls_certificates import ( - CertificateAvailableEvent, - CertificateExpiringEvent, - TLSCertificatesRequiresV2, - generate_csr, - generate_private_key, -) -from constants import ( - MYSQL_DATA_DIR, - TLS_RELATION, - TLS_SSL_CA_FILE, - TLS_SSL_CERT_FILE, - TLS_SSL_KEY_FILE, -) -from mysql_shell.models import InstanceState -from ops.charm import ActionEvent -from ops.framework import Object -from ops.model import ActiveStatus, BlockedStatus, MaintenanceStatus - -logger = logging.getLogger(__name__) - -LIBID = "eb73947deedd4380a3a90d527e0878eb" -LIBAPI = 0 -LIBPATCH = 12 - -PYDEPS = ["mysql_shell_client ~= 0.7"] - -SCOPE = "unit" - -if typing.TYPE_CHECKING: - from .mysql import MySQLCharmBase - - -class MySQLTLS(Object): - """MySQL TLS Provider class.""" - - def __init__(self, charm: "MySQLCharmBase"): - super().__init__(charm, "certificates") - self.charm = charm - - self.certs = TLSCertificatesRequiresV2(self.charm, TLS_RELATION) - - self.framework.observe( - self.charm.on.set_tls_private_key_action, - self._on_set_tls_private_key, - ) - self.framework.observe( - self.charm.on[TLS_RELATION].relation_joined, self._on_tls_relation_joined - ) - self.framework.observe( - self.charm.on[TLS_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) - - # ======================= - # Event Handlers - # ======================= - def _on_set_tls_private_key(self, event: ActionEvent) -> None: - """Action for setting a TLS private key.""" - self._request_certificate(event.params.get("internal-key", None)) - - def _on_tls_relation_joined(self, event) -> None: - """Request certificate when TLS relation joined.""" - if not self.charm.unit_initialized(): - event.defer() - return - self._request_certificate(None) - - def _on_certificate_available(self, event: CertificateAvailableEvent) -> None: - """Enable TLS when TLS certificate available.""" - if ( - event.certificate_signing_request.strip() - != self.charm.get_secret(SCOPE, "csr").strip() - ): - logger.error("An unknown certificate expiring.") - return - - if self.charm.unit_peer_data.get("tls") == "enabled": - logger.debug("TLS is already enabled.") - return - - state = self.charm._mysql.get_member_state() - if state != InstanceState.ONLINE: - logger.debug("Unit not initialized yet, deferring TLS configuration.") - event.defer() - return - - self.charm.unit.status = MaintenanceStatus("Setting up TLS") - - self.charm.set_secret( - SCOPE, "chain", "\n".join(event.chain) if event.chain is not None else None - ) - self.charm.set_secret(SCOPE, "certificate", event.certificate) - self.charm.set_secret(SCOPE, "certificate-authority", event.ca) - - self.push_tls_files_to_workload() - try: - self.charm._mysql.tls_setup( - ca_path=f"{MYSQL_DATA_DIR}/{TLS_SSL_CA_FILE}", - key_path=f"{MYSQL_DATA_DIR}/{TLS_SSL_KEY_FILE}", - cert_path=f"{MYSQL_DATA_DIR}/{TLS_SSL_CERT_FILE}", - require_tls=True, - ) - - # kill all sessions to force clients to reconnect - self.charm._mysql.kill_client_sessions() - except MySQLTLSSetupError: - logger.error("Failed to set custom TLS configuration.") - self.charm.unit.status = BlockedStatus("Failed to set TLS configuration.") - return - except MySQLKillSessionError: - logger.warning("Failed to kill unencrypted sessions.") - # set tls flag for unit - self.charm.unit_peer_data.update({"tls": "enabled"}) - self.charm.unit.status = ActiveStatus() - - def _on_certificate_expiring(self, event: CertificateExpiringEvent) -> None: - """Request the new certificate when old certificate is expiring.""" - if event.certificate != self.charm.get_secret(SCOPE, "certificate"): - logger.error("An unknown certificate expiring.") - return - - key = self.charm.get_secret(SCOPE, "key").encode("utf-8") - old_csr = self.charm.get_secret(SCOPE, "csr").encode("utf-8") - new_csr = generate_csr( - private_key=key, - subject=self.charm.get_unit_hostname(), - organization=self.charm.app.name, - sans=self._get_sans(), - ) - self.certs.request_certificate_renewal( - old_certificate_signing_request=old_csr, - new_certificate_signing_request=new_csr, - ) - self.charm.set_secret(SCOPE, "csr", new_csr.decode("utf-8")) - - def _on_tls_relation_broken(self, _) -> None: - """Disable TLS when TLS relation broken.""" - if self.charm.removing_unit: - logger.debug("Unit is being removed, skipping TLS cleanup.") - return - - try: - self.charm._mysql.tls_setup() - self.charm.unit_peer_data.pop("tls") - except MySQLTLSSetupError: - logger.error("Failed to restore default TLS configuration.") - self.charm.unit.status = BlockedStatus("Failed to restore default TLS configuration.") - - # ======================= - # Helpers - # ======================= - def _request_certificate(self, param: str | None): - """Request a certificate to TLS Certificates Operator.""" - key = generate_private_key() if param is None else self._parse_tls_file(param) - - csr = generate_csr( - private_key=key, - subject=self.charm.get_unit_hostname(), - organization=self.charm.app.name, - sans=self._get_sans(), - ) - - # store secrets - self.charm.set_secret(SCOPE, "key", key.decode("utf-8")) - self.charm.set_secret(SCOPE, "csr", csr.decode("utf-8")) - # set control flag - self.charm.unit_peer_data.update({"tls": "requested"}) - if self.charm.model.get_relation(TLS_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 ]+-+)", - "\n\\1\n", - raw_content, - ).encode("utf-8") - return base64.b64decode(raw_content) - - def _get_sans(self) -> list[str]: - """Create a list of DNS names for a unit. - - Returns: - A list representing the hostnames of the unit. - """ - unit_id = self.charm.unit.name.split("/")[1] - return [ - f"{self.charm.app.name}-{unit_id}", - socket.getfqdn(), - str(self.charm.model.get_binding(self.charm.peers).network.bind_address), - ] - - def get_tls_content(self) -> tuple[str | None, str | None, str | None]: - """Retrieve TLS content. - - Return TLS files as required by mysql. - - Returns: - A tuple of strings with the content of server-key, ca and server-cert - """ - ca = self.charm.get_secret(SCOPE, "certificate-authority") - chain = self.charm.get_secret(SCOPE, "chain") - ca_file = chain or ca - - key = self.charm.get_secret(SCOPE, "key") - cert = self.charm.get_secret(SCOPE, "certificate") - return key, ca_file, cert - - def push_tls_files_to_workload(self) -> None: - """Push TLS files to unit.""" - ssl_key, ssl_ca, ssl_cert = self.get_tls_content() - - if ssl_key: - self.charm._mysql.write_content_to_file( - f"{MYSQL_DATA_DIR}/{TLS_SSL_KEY_FILE}", ssl_key, permission=0o400 - ) - - if ssl_ca: - self.charm._mysql.write_content_to_file( - f"{MYSQL_DATA_DIR}/{TLS_SSL_CA_FILE}", ssl_ca, permission=0o400 - ) - - if ssl_cert: - self.charm._mysql.write_content_to_file( - f"{MYSQL_DATA_DIR}/{TLS_SSL_CERT_FILE}", ssl_cert, permission=0o400 - ) From e29446e8a4efb16ade489bff2a4e85eef1f0838f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sinclert=20P=C3=A9rez?= Date: Wed, 15 Apr 2026 17:34:14 +0200 Subject: [PATCH 8/9] [VM] Define TLS relation handler --- machines/lib/charms/mysql/v0/mysql.py | 13 +- machines/metadata.yaml | 2 +- machines/src/charm.py | 7 +- machines/src/constants.py | 2 +- machines/src/relations/tls.py | 175 ++++++++++++++++++ machines/terraform/outputs.tf | 6 +- .../tests/integration/integration/test_tls.py | 6 +- machines/tests/unit/test_mysql.py | 12 +- 8 files changed, 199 insertions(+), 24 deletions(-) create mode 100644 machines/src/relations/tls.py diff --git a/machines/lib/charms/mysql/v0/mysql.py b/machines/lib/charms/mysql/v0/mysql.py index f222dfc80..133bffe36 100644 --- a/machines/lib/charms/mysql/v0/mysql.py +++ b/machines/lib/charms/mysql/v0/mysql.py @@ -1299,14 +1299,11 @@ def configure_mysql_system_roles(self) -> None: def drop_root_user(self) -> None: """Drop the root user from the instance.""" - logger.debug("Dropping root user after initial setup") - client = MySQLInstanceClient( - self._build_instance_tcp_executor(self.instance_address), - self._quoter, - ) user = User("root", "localhost") + try: - client.delete_instance_user(user) + logger.debug("Dropping root user after initial setup") + self._instance_client_tcp.delete_instance_user(user) except ExecutionError as e: logger.error(f"Failed to drop root user for {self.instance_address}") raise MySQLDropRootUserError() from e @@ -2916,14 +2913,14 @@ def _execute_commands( """Execute commands on the server where MySQL is running.""" raise NotImplementedError - def tls_setup( + def setup_client_tls( self, ca_path: str = "ca.pem", key_path: str = "server-key.pem", cert_path: str = "server-cert.pem", require_tls: bool = False, ) -> None: - """Setup TLS files and requirement mode.""" + """Setup client-connection TLS files and requirement mode.""" tls_var = "require_secure_transport" tls_val = "ON" if require_tls else "OFF" diff --git a/machines/metadata.yaml b/machines/metadata.yaml index 234ac6732..68780aab9 100644 --- a/machines/metadata.yaml +++ b/machines/metadata.yaml @@ -41,7 +41,7 @@ provides: limit: 1 requires: - certificates: + client-certificates: interface: tls-certificates limit: 1 optional: true diff --git a/machines/src/charm.py b/machines/src/charm.py index 0afefc263..7ba504907 100755 --- a/machines/src/charm.py +++ b/machines/src/charm.py @@ -48,7 +48,6 @@ MySQLSetClusterPrimaryError, MySQLUnableToGetMemberStateError, ) -from charms.mysql.v0.tls import MySQLTLS from charms.rolling_ops.v0.rollingops import RollingOpsManager from object_storage import S3Requirer from ops import ( @@ -115,6 +114,7 @@ ) from refresh import MachinesMySQLRefresh from relations.mysql_provider import MySQLProvider +from relations.tls import TLS from utils import compare_dictionaries, generate_random_password logger = logging.getLogger(__name__) @@ -166,7 +166,7 @@ def __init__(self, *args): self.mysql_config = MySQLConfig() self.database_relation = MySQLProvider(self) - self.tls = MySQLTLS(self) + self.tls = TLS(self) self._grafana_agent = COSAgentProvider( self, metrics_endpoints=[ @@ -814,6 +814,9 @@ def workload_initialise(self) -> None: def get_unit_address(self, unit: Unit, relation_name: str) -> str: """Get the IP address of a specific unit.""" + if not self.peers: + return "" + try: return str(self.peers.data[unit].get(f"{relation_name}-address", "")) except KeyError: diff --git a/machines/src/constants.py b/machines/src/constants.py index be15e3cb3..986a98f37 100644 --- a/machines/src/constants.py +++ b/machines/src/constants.py @@ -19,7 +19,7 @@ MONITORING_PASSWORD_KEY = "monitoring-password" # noqa: S105 BACKUPS_PASSWORD_KEY = "backups-password" # noqa: S105 -TLS_RELATION = "certificates" +TLS_CLIENT_RELATION = "client-certificates" TLS_SSL_CA_FILE = "custom-ca.pem" TLS_SSL_KEY_FILE = "custom-server-key.pem" TLS_SSL_CERT_FILE = "custom-server-cert.pem" diff --git a/machines/src/relations/tls.py b/machines/src/relations/tls.py new file mode 100644 index 000000000..a818fb0f2 --- /dev/null +++ b/machines/src/relations/tls.py @@ -0,0 +1,175 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +"""TLS Handler.""" + +import logging +import socket +from typing import TYPE_CHECKING + +from charmlibs.interfaces.tls_certificates import ( + CertificateRequestAttributes, + TLSCertificatesRequiresV4, +) +from charms.mysql.v0.mysql import MySQLTLSSetupError +from mysql_shell.models import InstanceState +from ops.framework import EventBase, EventSource, Object +from ops.model import BlockedStatus, MaintenanceStatus +from ops.pebble import ConnectionError as PebbleConnectionError +from ops.pebble import PathError, ProtocolError + +from constants import ( + DB_RELATION_NAME, + MYSQL_DATA_DIR, + TLS_CLIENT_RELATION, + TLS_SSL_CA_FILE, + TLS_SSL_CERT_FILE, + TLS_SSL_KEY_FILE, +) + +if TYPE_CHECKING: + from charm import MySQLOperatorCharm + +logger = logging.getLogger(__name__) + + +class RefreshTLSCertificatesEvent(EventBase): + """Event for refreshing TLS certificates.""" + + +class TLS(Object): + """In this class we manage certificates relation.""" + + refresh_tls_certificates_event = EventSource(RefreshTLSCertificatesEvent) + + def __init__(self, charm: "MySQLOperatorCharm"): + super().__init__(charm, "certificates") + self.charm = charm + self.unit_name = charm.unit.name.replace("/", "-") + + self._common_hosts = { + self.unit_name, + } + if fqdn := socket.getfqdn(): + self._common_hosts.add(fqdn) + + self.client_certificate = TLSCertificatesRequiresV4( + self.charm, + TLS_CLIENT_RELATION, + certificate_requests=[ + CertificateRequestAttributes( + common_name=self._get_client_common_name(), + sans_dns={ + *self._common_hosts, + *self._get_client_addresses(), + }, + ), + ], + refresh_events=[self.refresh_tls_certificates_event], + ) + + self.framework.observe( + self.client_certificate.on.certificate_available, self._on_certificate_available + ) + self.framework.observe( + self.charm.on[TLS_CLIENT_RELATION].relation_broken, self._on_relation_broken + ) + + def _get_client_common_name(self) -> str: + """Get a common name for the certificate attributes.""" + return self.charm.get_unit_address(self.charm.unit, DB_RELATION_NAME) or self.unit_name + + def _get_client_addresses(self) -> set[str]: + """Get a set of client connection addresses for the certificate attributes.""" + client_addresses = set() + if addr := self.charm.get_unit_address(self.charm.unit, DB_RELATION_NAME): + client_addresses.add(addr) + + return client_addresses + + def _get_client_tls_files(self) -> tuple[str | None, str | None, str | None]: + """Prepare TLS files in special MySQL way. + + MySQL needs three files: + — CA file should have a full chain. + — Key file should have private key. + — Certificate file should have certificate without certificate chain. + """ + ca_file = None + cert_file = None + key_file = None + + certs, private_key = self.client_certificate.get_assigned_certificates() + if private_key: + key_file = str(private_key) + if certs: + cert_file = str(certs[0].certificate) + ca_file = str(certs[0].ca) + + return key_file, ca_file, cert_file + + def _on_certificate_available(self, event: EventBase) -> None: + """Handler for the certificate available event.""" + state = self.charm._mysql.get_member_state() + if state != InstanceState.ONLINE: + logger.debug("Unit not initialized yet, deferring TLS configuration.") + event.defer() + return + + self.charm.unit.status = MaintenanceStatus("Enabling TLS") + + try: + self._push_tls_files_to_workload() + except (PebbleConnectionError, PathError, ProtocolError) as e: + logger.error("Cannot push TLS certificates: %r", e) + event.defer() + return + + try: + self.charm._mysql.setup_client_tls( + ca_path=f"{MYSQL_DATA_DIR}/client_{TLS_SSL_CA_FILE}", + key_path=f"{MYSQL_DATA_DIR}/client_{TLS_SSL_KEY_FILE}", + cert_path=f"{MYSQL_DATA_DIR}/client_{TLS_SSL_CERT_FILE}", + require_tls=True, + ) + self.charm._mysql.kill_client_sessions() + except MySQLTLSSetupError: + logger.error("Failed to enable TLS configuration.") + self.charm.unit.status = BlockedStatus("Failed to enable TLS configuration.") + return + + self.charm.unit.status = self.charm.build_unit_workload_status() + + def _on_relation_broken(self, _: EventBase) -> None: + """Handler for the relation broken event.""" + if self.charm.removing_unit: + logger.debug("Unit is being removed, skipping TLS cleanup.") + return + + self.charm.unit.status = MaintenanceStatus("Disabling TLS") + + try: + self.charm._mysql.setup_client_tls() + self.charm._mysql.kill_client_sessions() + except MySQLTLSSetupError: + logger.error("Failed to disable TLS configuration.") + self.charm.unit.status = BlockedStatus("Failed to disable TLS configuration.") + return + + self.charm.unit.status = self.charm.build_unit_workload_status() + + def _push_tls_files_to_workload(self) -> None: + """Push TLS files to unit.""" + key, ca, cert = self._get_client_tls_files() + if key: + self.charm._mysql.write_content_to_file( + f"{MYSQL_DATA_DIR}/client_{TLS_SSL_KEY_FILE}", key, permission=0o400 + ) + if ca: + self.charm._mysql.write_content_to_file( + f"{MYSQL_DATA_DIR}/client_{TLS_SSL_CA_FILE}", ca, permission=0o400 + ) + if cert: + self.charm._mysql.write_content_to_file( + f"{MYSQL_DATA_DIR}/client_{TLS_SSL_CERT_FILE}", cert, permission=0o400 + ) diff --git a/machines/terraform/outputs.tf b/machines/terraform/outputs.tf index 06fa955a1..41675eddf 100644 --- a/machines/terraform/outputs.tf +++ b/machines/terraform/outputs.tf @@ -14,8 +14,8 @@ output "provides" { output "requires" { description = "Map of all the required endpoints" value = { - certificates = "certificates" - s3_parameters = "s3-parameters" - tracing = "tracing" + client_certificates = "client-certificates" + s3_parameters = "s3-parameters" + tracing = "tracing" } } diff --git a/machines/tests/integration/integration/test_tls.py b/machines/tests/integration/integration/test_tls.py index 4eea5def1..8088ba927 100644 --- a/machines/tests/integration/integration/test_tls.py +++ b/machines/tests/integration/integration/test_tls.py @@ -99,7 +99,7 @@ def test_enable_tls(juju: Juju) -> None: # Relate with TLS charm logger.info("Relate to TLS operator") - juju.integrate(APP_NAME, TLS_APP_NAME) + juju.integrate(f"{APP_NAME}:client-certificates", f"{TLS_APP_NAME}:certificates") # Wait for hooks start reconfiguring app # add as a wait since app state does not change @@ -186,7 +186,7 @@ def test_disable_tls(juju: Juju) -> None: app_units = get_app_units(juju, APP_NAME) logger.info("Removing relation") - juju.remove_relation(f"{APP_NAME}:certificates", f"{TLS_APP_NAME}:certificates") + juju.remove_relation(f"{APP_NAME}:client-certificates", f"{TLS_APP_NAME}:certificates") # Allow time for reconfigure sleep(TLS_SETUP_SLEEP_TIME) @@ -220,7 +220,7 @@ def get_tls_ca(juju: Juju, unit_name: str) -> str: # Filter the data based on the relation name. relation_data = [ - v for v in unit_info[unit_name]["relation-info"] if v["endpoint"] == "certificates" + v for v in unit_info[unit_name]["relation-info"] if v["endpoint"] == "client-certificates" ] if len(relation_data) == 0: return "" diff --git a/machines/tests/unit/test_mysql.py b/machines/tests/unit/test_mysql.py index 73992af4c..0bfa3de43 100644 --- a/machines/tests/unit/test_mysql.py +++ b/machines/tests/unit/test_mysql.py @@ -1543,8 +1543,8 @@ def test_delete_temp_restore_directory_failure(self, _execute_commands): group="test-group", ) - def test_tls_set_custom(self): - """Test the successful execution of tls_set_custom.""" + def test_setup_client_tls(self): + """Test the successful execution of setup_client_tls.""" queries = [ "SET @@PERSIST.`ssl_ca` = 'ca_path'", "SET @@PERSIST.`ssl_key` = 'key_path'", @@ -1553,11 +1553,11 @@ def test_tls_set_custom(self): "ALTER INSTANCE RELOAD TLS", ] - self.mysql.tls_setup("ca_path", "key_path", "cert_path", True) + self.mysql.setup_client_tls("ca_path", "key_path", "cert_path", True) self.mock_executor.execute_sql.assert_has_calls([call(query) for query in queries]) - def test_tls_restore_default(self): - """Test the successful execution of tls_set_custom.""" + def test_restore_client_tls(self): + """Test the successful execution of setup_client_tls.""" queries = [ "SET @@PERSIST.`ssl_ca` = 'ca.pem'", "SET @@PERSIST.`ssl_key` = 'server-key.pem'", @@ -1566,7 +1566,7 @@ def test_tls_restore_default(self): "ALTER INSTANCE RELOAD TLS", ] - self.mysql.tls_setup() + self.mysql.setup_client_tls() self.mock_executor.execute_sql.assert_has_calls([call(query) for query in queries]) def test_kill_client_sessions(self): From 5758b1563a4719ef2eaed8ce45c46db0029d8c54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sinclert=20P=C3=A9rez?= Date: Wed, 15 Apr 2026 17:35:33 +0200 Subject: [PATCH 9/9] [VM] Disable TLS rotation test --- .../tests/integration/integration/test_tls.py | 76 +------------------ 1 file changed, 1 insertion(+), 75 deletions(-) diff --git a/machines/tests/integration/integration/test_tls.py b/machines/tests/integration/integration/test_tls.py index 8088ba927..0e414adad 100644 --- a/machines/tests/integration/integration/test_tls.py +++ b/machines/tests/integration/integration/test_tls.py @@ -8,7 +8,7 @@ import jubilant from jubilant import Juju -from constants import MYSQL_DATA_DIR, REPLICATION_USERNAME, TLS_SSL_CERT_FILE +from constants import REPLICATION_USERNAME from ..helpers import ( is_connection_possible, @@ -129,58 +129,6 @@ def test_enable_tls(juju: Juju) -> None: assert get_tls_ca(juju, app_units[0]), "❌ No CA found after TLS relation" -def test_rotate_tls_key(juju: Juju) -> None: - """Verify rotating tls private keys restarts cluster with new certificates. - - This test rotates tls private keys to randomly generated keys. - """ - app_units = get_app_units(juju, APP_NAME) - # dict of values for cert file md5sum. After resetting the - # private keys these certificates should be updated. - original_tls = {} - for unit_name in app_units: - original_tls[unit_name] = {} - original_tls[unit_name]["cert"] = unit_file_md5( - juju, unit_name, f"{MYSQL_DATA_DIR}/{TLS_SSL_CERT_FILE}" - ) - - # set key using auto-generated key for each unit - # not asserting actions run due false positives on CI - for unit_name in app_units: - task = juju.run( - unit=unit_name, - action="set-tls-private-key", - ) - task.raise_on_failure() - - # Wait for hooks start reconfiguring app - # add as a wait since app state does not change - # due tls setup running too briefly - sleep(TLS_SETUP_SLEEP_TIME) - - # After updating both the external key and the internal key a new certificate request will be - # made; then the certificates should be available and updated. - for unit_name in app_units: - new_cert_md5 = unit_file_md5(juju, unit_name, f"{MYSQL_DATA_DIR}/{TLS_SSL_CERT_FILE}") - - assert new_cert_md5 != original_tls[unit_name]["cert"], ( - f"cert for {unit_name} was not updated." - ) - - # Asserting only encrypted connection should be possible - logger.info("Asserting connections after relation") - for unit_name in app_units: - unit_ip = get_unit_ip(juju, APP_NAME, unit_name) - config["host"] = unit_ip - assert is_connection_possible(config, **{"ssl_disabled": False}), ( - f"❌ Encrypted connection not possible to unit {unit_name} with enabled TLS" - ) - - assert not is_connection_possible(config, **{"ssl_disabled": True}), ( - f"❌ Unencrypted connection possible to unit {unit_name} with enabled TLS" - ) - - def test_disable_tls(juju: Juju) -> None: # Remove the relation app_units = get_app_units(juju, APP_NAME) @@ -225,25 +173,3 @@ def get_tls_ca(juju: Juju, unit_name: str) -> str: if len(relation_data) == 0: return "" return json.loads(relation_data[0]["application-data"]["certificates"])[0].get("ca") - - -def unit_file_md5(juju: Juju, unit_name: str, file_path: str) -> str | None: - """Return md5 hash for given file. - - Args: - juju: The Juju instance - unit_name: The name of the unit - file_path: The path to the file - - Returns: - md5sum hash string - """ - try: - md5sum_raw = juju.ssh( - target=unit_name, - command=f"sudo md5sum {file_path}", - ) - return md5sum_raw.strip().split()[0] - except Exception as exc: - logger.exception("Exception while calculating md5 hash for file", exc_info=exc) - return