diff --git a/lib/charms/observability_libs/v0/cert_handler.py b/lib/charms/observability_libs/v0/cert_handler.py index 15087be5..88a8374e 100644 --- a/lib/charms/observability_libs/v0/cert_handler.py +++ b/lib/charms/observability_libs/v0/cert_handler.py @@ -33,8 +33,10 @@ This library requires a peer relation to be declared in the requirer's metadata. Peer relation data is used for "persistent storage" of the private key and certs. """ +import ipaddress import json import socket +from itertools import filterfalse from typing import List, Optional, Union try: @@ -62,7 +64,16 @@ LIBID = "b5cd5cd580f3428fa5f59a8876dcbe6a" LIBAPI = 0 -LIBPATCH = 7 +LIBPATCH = 8 + + +def is_ip_address(value: str) -> bool: + """Return True if the input value is a valid IPv4 address; False otherwise.""" + try: + ipaddress.IPv4Address(value) + return True + except ipaddress.AddressValueError: + return False class CertChanged(EventBase): @@ -88,7 +99,7 @@ def __init__( peer_relation_name: str, certificates_relation_name: str = "certificates", cert_subject: Optional[str] = None, - extra_sans_dns: Optional[List[str]] = None, + extra_sans_dns: Optional[List[str]] = None, # TODO: in v1, rename arg to `sans` ): """CertHandler is used to wrap TLS Certificates management operations for charms. @@ -111,7 +122,9 @@ def __init__( self.cert_subject = charm.unit.name.replace("/", "-") if not cert_subject else cert_subject # Use fqdn only if no SANs were given, and drop empty/duplicate SANs - self.sans_dns = list(set(filter(None, (extra_sans_dns or [socket.getfqdn()])))) + sans = list(set(filter(None, (extra_sans_dns or [socket.getfqdn()])))) + self.sans_ip = list(filter(is_ip_address, sans)) + self.sans_dns = list(filterfalse(is_ip_address, sans)) self.peer_relation_name = peer_relation_name self.certificates_relation_name = certificates_relation_name @@ -229,6 +242,7 @@ def _generate_csr( private_key=private_key.encode(), subject=self.cert_subject, sans_dns=self.sans_dns, + sans_ip=self.sans_ip, ) if renew and self._csr: @@ -237,7 +251,12 @@ def _generate_csr( new_certificate_signing_request=csr, ) else: - logger.info("Creating CSR for %s with DNS %s", self.cert_subject, self.sans_dns) + logger.info( + "Creating CSR for %s with DNS %s and IPs %s", + self.cert_subject, + self.sans_dns, + self.sans_ip, + ) self.certificates.request_certificate_creation(certificate_signing_request=csr) # Note: CSR is being replaced with a new one, so until we get the new cert, we'd have diff --git a/lib/charms/tempo_k8s/v0/charm_tracing.py b/lib/charms/tempo_k8s/v0/charm_tracing.py index a5e34330..5939fff9 100644 --- a/lib/charms/tempo_k8s/v0/charm_tracing.py +++ b/lib/charms/tempo_k8s/v0/charm_tracing.py @@ -86,7 +86,7 @@ def tracer(self) -> opentelemetry.trace.Tracer: # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 2 +LIBPATCH = 3 PYDEPS = ["opentelemetry-exporter-otlp-proto-grpc==1.17.0"] @@ -313,7 +313,7 @@ def trace_charm( method calls on instances of this class. Usage: - >>> from charms.tempo_k8s.v0.charm_instrumentation import trace_charm + >>> from charms.tempo_k8s.v0.charm_tracing import trace_charm >>> from charms.tempo_k8s.v0.tracing import TracingEndpointProvider >>> from ops import CharmBase >>> @@ -370,7 +370,7 @@ def _autoinstrument( Usage: - >>> from charms.tempo_k8s.v0.charm_instrumentation import _autoinstrument + >>> from charms.tempo_k8s.v0.charm_tracing import _autoinstrument >>> from ops.main import main >>> _autoinstrument( >>> MyCharm, diff --git a/lib/charms/tls_certificates_interface/v2/tls_certificates.py b/lib/charms/tls_certificates_interface/v2/tls_certificates.py index fa36004e..4231e533 100644 --- a/lib/charms/tls_certificates_interface/v2/tls_certificates.py +++ b/lib/charms/tls_certificates_interface/v2/tls_certificates.py @@ -298,7 +298,7 @@ def _on_all_certificates_invalidated(self, event: AllCertificatesInvalidatedEven ) from ops.framework import EventBase, EventSource, Handle, Object from ops.jujuversion import JujuVersion -from ops.model import SecretNotFoundError +from ops.model import Relation, SecretNotFoundError # The unique Charmhub library identifier, never change it LIBID = "afd8c2bccf834997afce12c2706d2ede" @@ -308,7 +308,7 @@ def _on_all_certificates_invalidated(self, event: AllCertificatesInvalidatedEven # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 12 +LIBPATCH = 14 PYDEPS = ["cryptography", "jsonschema"] @@ -640,6 +640,17 @@ def generate_ca( 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) @@ -657,6 +668,7 @@ def generate_ca( ), critical=False, ) + .add_extension(key_usage, critical=True) .add_extension( x509.BasicConstraints(ca=True, path_length=None), critical=True, @@ -897,6 +909,22 @@ def __init__(self, charm: CharmBase, relationship_name: str): self.charm = charm self.relationship_name = relationship_name + def _load_app_relation_data(self, relation: Relation) -> dict: + """Loads relation data from the application relation data bag. + + Json loads all data. + + Args: + relation_object: 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, @@ -931,7 +959,7 @@ def _add_certificate( "ca": ca, "chain": chain, } - provider_relation_data = _load_relation_data(relation.data[self.charm.app]) + 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: @@ -964,7 +992,7 @@ def _remove_certificate( raise RuntimeError( f"Relation {self.relationship_name} with relation id {relation_id} does not exist" ) - provider_relation_data = _load_relation_data(relation.data[self.charm.app]) + 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: @@ -999,7 +1027,7 @@ def revoke_all_certificates(self) -> None: 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 = _load_relation_data(relation.data[self.charm.app]) + 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 @@ -1081,7 +1109,7 @@ def get_issued_certificates( else self.model.relations.get(self.relationship_name, []) ) for relation in relations: - provider_relation_data = _load_relation_data(relation.data[self.charm.app]) + provider_relation_data = self._load_app_relation_data(relation) provider_certificates = provider_relation_data.get("certificates", []) certificates[relation.app.name] = [] # type: ignore[union-attr] @@ -1111,9 +1139,13 @@ def _on_relation_changed(self, event: RelationChangedEvent) -> None: Returns: None """ - assert event.unit is not 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 = _load_relation_data(event.relation.data[self.charm.app]) + 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 @@ -1152,7 +1184,7 @@ def _revoke_certificates_for_which_no_csr_exists(self, relation_id: int) -> None ) if not certificates_relation: raise RuntimeError(f"Relation {self.relationship_name} does not exist") - provider_relation_data = _load_relation_data(certificates_relation.data[self.charm.app]) + 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]) diff --git a/src/charm.py b/src/charm.py index 36bf1fbc..7759fe7d 100755 --- a/src/charm.py +++ b/src/charm.py @@ -91,6 +91,20 @@ BIN_PATH = "/usr/bin/traefik" +def is_hostname(value: Optional[str]) -> bool: + """Return False if input value is an IP address; True otherwise.""" + if value is None: + return False + + try: + ipaddress.ip_address(value) + # No exception raised so this is an IP address. + return False + except ValueError: + # This is not an IP address so assume it's a hostname. + return bool(value) + + class _RoutingMode(enum.Enum): path = "path" subdomain = "subdomain" @@ -495,6 +509,16 @@ def _get_tls_config(self) -> dict: "keyFile": _SERVER_KEY_PATH, } ], + "stores": { + "default": { + # When the external hostname is a bare IP, traefik cannot match a domain, + # so we must set the default cert for the TLS handshake to succeed. + "defaultCertificate": { + "certFile": _SERVER_CERT_PATH, + "keyFile": _SERVER_KEY_PATH, + }, + }, + }, } } @@ -1057,19 +1081,25 @@ def _generate_tls_block( service_name: str, ) -> Dict[str, Any]: """Generate a TLS configuration segment.""" + tls_entry = ( + { + "domains": [ + { + "main": self.external_host, + "sans": [f"*.{self.external_host}"], + }, + ], + } + if is_hostname(self.external_host) + else {} # When the external host is a bare IP, we do not need the 'domains' entry. + ) + return { f"{router_name}-tls": { "rule": route_rule, "service": service_name, "entryPoints": ["websecure"], - "tls": { - "domains": [ - { - "main": self.external_host, - "sans": [f"*.{self.external_host}"], - }, - ], - }, + "tls": tls_entry, } } @@ -1256,18 +1286,6 @@ def server_cert_sans_dns(self) -> List[str]: """Provide certificate SANs DNS.""" target = self.external_host - def is_hostname(st: Optional[str]) -> bool: - if st is None: - return False - - try: - ipaddress.ip_address(st) - # No exception raised so this is an IP address. - return False - except ValueError: - # This is not an IP address so assume it's a hostname. - return bool(st) - if is_hostname(target): assert isinstance(target, str) # for type checker return [target] diff --git a/tests/manual/bundle_1_http.yaml b/tests/manual/bundle_1_http.yaml index bcc1de9a..6441a139 100644 --- a/tests/manual/bundle_1_http.yaml +++ b/tests/manual/bundle_1_http.yaml @@ -16,7 +16,7 @@ applications: series: focal scale: 1 resources: - traefik-image: docker.io/jnsgruk/traefik:2.9.6 + traefik-image: ghcr.io/canonical/traefik:2.10.4 relations: - [am:ingress, trfk] diff --git a/tests/manual/bundle_3_reverse_termination.yaml b/tests/manual/bundle_3_reverse_termination.yaml index ebc807a1..85e7728a 100644 --- a/tests/manual/bundle_3_reverse_termination.yaml +++ b/tests/manual/bundle_3_reverse_termination.yaml @@ -16,7 +16,7 @@ applications: series: focal scale: 1 resources: - traefik-image: docker.io/jnsgruk/traefik:2.9.6 + traefik-image: ghcr.io/canonical/traefik:2.10.4 ca: charm: self-signed-certificates diff --git a/tests/manual/bundle_4_tls_termination.yaml b/tests/manual/bundle_4_tls_termination.yaml index a47cd6ac..fbfa2bb9 100644 --- a/tests/manual/bundle_4_tls_termination.yaml +++ b/tests/manual/bundle_4_tls_termination.yaml @@ -16,15 +16,15 @@ applications: series: focal scale: 1 resources: - traefik-image: docker.io/jnsgruk/traefik:2.9.6 + traefik-image: ghcr.io/canonical/traefik:2.10.4 options: external_hostname: cluster.local - ca: + external-ca: charm: self-signed-certificates channel: edge scale: 1 relations: - [am:ingress, trfk] -- [trfk:certificates, ca:certificates] +- [trfk:certificates, external-ca:certificates] diff --git a/tests/manual/bundle_5_tls_end_to_end.yaml b/tests/manual/bundle_5_tls_end_to_end.yaml index 64935cbe..f08e1577 100644 --- a/tests/manual/bundle_5_tls_end_to_end.yaml +++ b/tests/manual/bundle_5_tls_end_to_end.yaml @@ -43,7 +43,7 @@ applications: series: focal scale: 1 resources: - traefik-image: docker.io/jnsgruk/traefik:2.9.6 + traefik-image: ghcr.io/canonical/traefik:2.10.4 options: external_hostname: cluster.local @@ -52,7 +52,12 @@ applications: channel: edge scale: 1 + external-ca: + charm: self-signed-certificates + channel: edge + scale: 1 + relations: - [am:ingress, trfk] - [am:certificates, ca:certificates] -- [trfk:certificates, ca:certificates] +- [trfk:certificates, external-ca:certificates]