Skip to content

Commit

Permalink
Set default cert (#249)
Browse files Browse the repository at this point in the history
* Set default cert
* Update bundles for manual testing
* fetch-lib
  • Loading branch information
sed-i authored Sep 22, 2023
1 parent 4086e3b commit 57ce671
Show file tree
Hide file tree
Showing 8 changed files with 117 additions and 43 deletions.
27 changes: 23 additions & 4 deletions lib/charms/observability_libs/v0/cert_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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):
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions lib/charms/tempo_k8s/v0/charm_tracing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand Down Expand Up @@ -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
>>>
Expand Down Expand Up @@ -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,
Expand Down
50 changes: 41 additions & 9 deletions lib/charms/tls_certificates_interface/v2/tls_certificates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"]

Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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])
Expand Down
58 changes: 38 additions & 20 deletions src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
},
},
},
}
}

Expand Down Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion tests/manual/bundle_1_http.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
2 changes: 1 addition & 1 deletion tests/manual/bundle_3_reverse_termination.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions tests/manual/bundle_4_tls_termination.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
9 changes: 7 additions & 2 deletions tests/manual/bundle_5_tls_end_to_end.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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]

0 comments on commit 57ce671

Please sign in to comment.