Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -162,3 +162,6 @@ cython_debug/
.vscode/

.stestr/

# Git worktrees
.worktrees/
41 changes: 41 additions & 0 deletions sunbeam-python/sunbeam/commands/refresh.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from sunbeam.core.openstack import OPENSTACK_MODEL
from sunbeam.core.terraform import TerraformInitStep
from sunbeam.features.interface.v1.base import is_maas_deployment
from sunbeam.features.tls.ca import CaTlsFeature
from sunbeam.steps.horizon import AttachHorizonThemeStep
from sunbeam.steps.k8s import DeployK8SApplicationStep
from sunbeam.steps.k8s_upgrade import K8SCharmUpgradeStep
Expand Down Expand Up @@ -73,6 +74,22 @@ def _stored_manifest_risk(client) -> str | None:
return risk_counts.most_common(1)[0][0]


def _fix_tls_certs(deployment: Deployment, show_hints: bool) -> None:
"""Re-provide stored tls.ca certificates for any outstanding CSRs.

No-op when tls.ca is not the active TLS provider.
"""
feature = deployment.get_feature_manager().resolve_feature(CaTlsFeature.name)
if not isinstance(feature, CaTlsFeature) or not feature.is_enabled(
deployment.get_client()
):
LOG.debug("tls.ca is not enabled; skipping TLS certificate re-provision.")
return

feature.reapply_certificates(deployment, show_hints)
click.echo("TLS certificate re-provision complete.")


@click.group("refresh", invoke_without_command=True)
@click.option(
"-c",
Expand Down Expand Up @@ -205,6 +222,12 @@ def refresh(
show_hints,
)

if not upgrade_release:
# Recover deployments affected by the traefik certificate bug by
# re-providing stored tls.ca certificates for any outstanding CSRs.
# No-op unless tls.ca is enabled and there are outstanding requests.
_fix_tls_certs(deployment, show_hints)

click.echo("Refresh complete.")


Expand Down Expand Up @@ -419,3 +442,21 @@ def refresh_k8s(
if message:
click.echo(message)
click.echo("k8s refresh complete.")


@refresh.command("certificates")
@click_option_show_hints
@click.pass_context
def refresh_certificates(
ctx: click.Context,
show_hints: bool = False,
) -> None:
"""Re-provide stored tls.ca certificates for outstanding CSRs.

This is the same as the last step of `sunbeam cluster refresh`
and it is a no-op unless tls.ca is enabled and there are outstanding csrs to be
reprovisioned.
"""
deployment: Deployment = ctx.obj
run_preflight_checks([JujuLoginCheck(deployment.juju_account)], console)
_fix_tls_certs(deployment, show_hints)
69 changes: 69 additions & 0 deletions sunbeam-python/sunbeam/features/interface/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import base64
import binascii
import datetime
import logging
import re
import typing
Expand Down Expand Up @@ -158,6 +159,52 @@ def get_subject_from_csr(csr: str) -> str | None:
return None


def get_cn_from_csr(csr: str) -> str | None:
"""Return the Common Name from a PEM-encoded CSR string, or None."""
try:
req = x509.load_pem_x509_csr(bytes(csr, "utf-8"))
cn_attrs = req.subject.get_attributes_for_oid(x509_oid.NameOID.COMMON_NAME)
if not cn_attrs:
return None
return str(cn_attrs[0].value)
except (binascii.Error, TypeError, ValueError) as e:
LOG.debug("Failed to get CN from CSR: %r", e)
return None


def get_cn_from_cert(certificate: str) -> str | None:
"""Return the Common Name from a base64-encoded PEM certificate, or None."""
try:
certificate_bytes = base64.b64decode(certificate)
cert = x509.load_pem_x509_certificate(certificate_bytes)
cn_attrs = cert.subject.get_attributes_for_oid(x509_oid.NameOID.COMMON_NAME)
if not cn_attrs:
return None
return str(cn_attrs[0].value)
except (binascii.Error, TypeError, ValueError) as e:
LOG.debug("Failed to get CN from certificate: %r", e)
return None


def is_cert_expired(certificate: str) -> bool:
"""Return True if the base64-encoded PEM certificate has expired.

Returns True (treat as expired) if the certificate cannot be decoded.
"""
try:
certificate_bytes = base64.b64decode(certificate)
cert = x509.load_pem_x509_certificate(certificate_bytes)
try:
expiry = cert.not_valid_after_utc
except AttributeError:
# cryptography < 42 returns a naive datetime; assume UTC
expiry = cert.not_valid_after.replace(tzinfo=datetime.timezone.utc)
return expiry < datetime.datetime.now(datetime.timezone.utc)
except (binascii.Error, TypeError, ValueError) as e:
LOG.debug("Failed to check certificate expiry: %r", e)
return True


def encode_base64_as_string(data: str) -> str | None:
try:
return base64.b64encode(bytes(data, "utf-8")).decode()
Expand All @@ -174,6 +221,28 @@ def decode_base64_as_string(data: str) -> str | None:
return None


def cert_and_csr_public_key_match(cert_b64: str, csr: str) -> bool:
"""Return True if the public key in the certificate matches the one in the CSR.

Returns False if either cannot be decoded.
"""
try:
cert = x509.load_pem_x509_certificate(base64.b64decode(cert_b64))
req = x509.load_pem_x509_csr(bytes(csr, "utf-8"))
cert_pub = cert.public_key()
csr_pub = req.public_key()
if hasattr(cert_pub, "public_numbers") and hasattr(csr_pub, "public_numbers"):
return cert_pub.public_numbers() == csr_pub.public_numbers()
if hasattr(cert_pub, "public_bytes_raw") and hasattr(
csr_pub, "public_bytes_raw"
):
return cert_pub.public_bytes_raw() == csr_pub.public_bytes_raw()
return False
except (binascii.Error, TypeError, ValueError) as e:
LOG.debug("Failed to compare public keys: %r", e)
return False


def cert_and_key_match(certificate: bytes, key: bytes) -> bool:
"""Checks if the supplied cert is derived from the supplied key."""
crt = x509.load_pem_x509_certificate(certificate, backends.default_backend())
Expand Down
68 changes: 61 additions & 7 deletions sunbeam-python/sunbeam/features/tls/ca.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,13 @@
from sunbeam.core.common import (
FORMAT_TABLE,
FORMAT_YAML,
ResultType,
get_step_result,
read_config,
run_plan,
str_presenter,
)
from sunbeam.core.deployment import Deployment
from sunbeam.core.juju import (
JujuHelper,
)
from sunbeam.core.manifest import (
AddManifestStep,
FeatureConfig,
Expand All @@ -45,6 +44,7 @@
CERTIFICATE_FEATURE_KEY,
INGRESS_CHANGE_APPLICATION_TIMEOUT,
ConfigureTLSCertificatesStep,
ReapplyTLSCertificatesStep,
TlsFeature,
TlsFeatureConfig,
certificate_questions,
Expand Down Expand Up @@ -180,6 +180,16 @@ def set_tfvars_on_resize(
"""Set terraform variables to resize the application."""
return {}

def _local_apps_to_monitor(self, deployment: Deployment) -> list[str]:
"""Local applications to monitor after a TLS certificate change."""
client = deployment.get_client()
apps = ["traefik", "traefik-public"]
if not deployment.external_keystone_model:
apps.append("keystone")
if client.cluster.list_nodes_by_role("storage"):
apps.append("traefik-rgw")
return apps

@click.group()
def ca_group(self) -> None:
"""Manage CA."""
Expand Down Expand Up @@ -242,9 +252,7 @@ def configure(
if (ca := manifest.get_feature(self.name.split(".")[-1])) and ca.config:
preseed = ca.config.model_dump(by_alias=True)
model = OPENSTACK_MODEL
apps_to_monitor = ["traefik", "traefik-public", "keystone"]
if client.cluster.list_nodes_by_role("storage"):
apps_to_monitor.append("traefik-rgw")
apps_to_monitor = self._local_apps_to_monitor(deployment)

try:
config = read_config(client, CERTIFICATE_FEATURE_KEY)
Expand All @@ -256,7 +264,7 @@ def configure(
if ca is None:
raise click.ClickException("CA is not configured")

jhelper = JujuHelper(deployment.juju_controller)
jhelper = deployment.get_juju_helper()
plan = [
AddManifestStep(client, manifest_path),
ConfigureTLSCertificatesStep(
Expand All @@ -276,6 +284,52 @@ def configure(
run_plan(plan, console, show_hints)
click.echo("CA certs configured")

def reapply_certificates(
self, deployment: Deployment, show_hints: bool = False
) -> None:
"""Re-provide stored certificates for any outstanding CSRs.

Used to recover a deployment after a charm refresh (e.g. the traefik
charm crossing revision 308) leaves applications with outstanding
certificate requests. Reuses the certificates already stored in
clusterd; never prompts. No-op when there are no outstanding CSRs or
none match a stored certificate.
"""
client = deployment.get_client()
try:
config = read_config(client, CERTIFICATE_FEATURE_KEY)
except ConfigItemNotFoundException:
config = {}
ca = config.get("ca")
ca_chain = config.get("chain")

if ca is None:
raise click.ClickException("CA is not configured")

model = OPENSTACK_MODEL
apps_to_monitor = self._local_apps_to_monitor(deployment)

jhelper = deployment.get_juju_helper()
rerun_provide_certificates_plan = [
ReapplyTLSCertificatesStep(client, jhelper, ca, ca_chain)
]
results = run_plan(rerun_provide_certificates_plan, console, show_hints)

reapply_result = get_step_result(results, ReapplyTLSCertificatesStep)
if reapply_result.result_type != ResultType.SKIPPED:
run_plan(
[
WaitForApplicationsStep(
jhelper,
apps_to_monitor,
model,
INGRESS_CHANGE_APPLICATION_TIMEOUT,
)
],
console,
show_hints,
)

def enabled_commands(self) -> dict[str, list[dict]]:
"""Dict of clickgroup along with commands.

Expand Down
Loading
Loading