Skip to content

Commit

Permalink
Merge pull request #79 from charmed-kubernetes/feature/arch/multiarch
Browse files Browse the repository at this point in the history
Remove python cryptography libraries in favor of subprocess to `openssl`
  • Loading branch information
kwmonroe authored Apr 21, 2023
2 parents 04be2cf + ec8ffec commit 2d1284a
Show file tree
Hide file tree
Showing 10 changed files with 355 additions and 47 deletions.
20 changes: 12 additions & 8 deletions charmcraft.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,20 @@

type: charm
bases:
- name: "ubuntu"
channel: "22.04"
- build-on:
- name: ubuntu
channel: "20.04"
run-on:
- name: ubuntu
channel: "20.04"
architectures:
- amd64
- arm64
- armhf
- ppc64
- s390x
parts:
charm:
charm-python-packages: [setuptools, pip]
build-packages:
- git
# required for installing cffi:
- libffi-dev
# required for installing cryptography (cargo also contains rust):
- cargo
- libssl-dev
- pkg-config
174 changes: 174 additions & 0 deletions lib/charms/kubernetes_dashboard/v1/cert.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
# Copyright 2021 Canonical Ltd.
# See LICENSE file for licensing details.

"""# Self-Signed Certificate Generator.
This charm library contains a class `SelfSignedCert` which can be used for generating self-signed
RSA certificates for use in TLS connections or otherwise. It does not currently provide much
configurability, apart from the FQDN the certificate should be associated with, a list of IP
addresses to be present in the Subject Alternative Name (SAN) field, validity and key length.
By default, generated certificates are valid for 365 years, and use a 2048-bit key size.
## Getting Started
In order to use this library, you will need to fetch the library from Charmhub as normal, but you
will also need to add a dependency on the `cryptography` package to your charm:
```shell
cd some-charm
charmcraft fetch-lib charms.kubernetes_dashboard.v1.cert
echo <<-EOF >> requirements.txt
cryptography
EOF
```
Once complete, you can import the charm and use it like so (in the most simple form):
```python
# ...
from charms.kubernetes_dashboard.v0.cert import SelfSignedCert
from ipaddress import IPv4Address
# Generate a certificate
self_signed_cert = SelfSigned(names=["test-service.dev"], ips=[IPv4Address("10.28.0.20")])
# Bytes representing the certificate in PEM format
certificate = self_signed_cert.cert
# Bytes representing the private key in PEM/PKCS8 format
key = self_signed_cert.key
```
You can also specify the validity period in days, and the required key size. The algorithm is
always RSA:
```python
# ...
from charms.kubernetes_dashboard.v0.cert import SelfSignedCert
from ipaddress import IPv4Address
# Generate a certificate
self_signed_cert = SelfSigned(
names=["some_app.my_namespace.svc.cluster.local"],
ips=[IPv4Address("10.41.150.12"), IPv4Address("192.168.0.20")],
key_size = 4096,
validity = 3650
)
```
"""

# The unique Charmhub library identifier, never change it
LIBID = "6b649bc0040448399cfc718a6fcba24d"

# Increment this major API version when introducing breaking changes
LIBAPI = 1

# Increment this PATCH version before using `charmcraft publish-lib` or reset
# to 0 if you are raising the major API version
LIBPATCH = 0


from datetime import datetime, timedelta
from ipaddress import IPv4Address
from typing import List
from pathlib import Path
from tempfile import NamedTemporaryFile
from subprocess import check_call, check_output, CalledProcessError


class SelfSignedCert:
"""A class used for generating self-signed RSA TLS certificates."""

def __init__(
self,
*,
names: List[str],
ips: List[IPv4Address] = [],
key_size: int = 2048,
validity: int = 365,
):
"""Initialise a new self-signed certificate.
Args:
names: A list of FQDNs that should be placed in the Subject Alternative
Name field of the certificate. The first name in the list will be
used as the Common Name, Subject and Issuer field.
ips: A list of IPv4Address objects that should be present in the list
of Subject Alternative Names of the certificate.
key_size: Size of the RSA Private Key to be generated. Defaults to 2048
validity: Period in days the certificate is valid for. Default is 365.
Raises:
ValueError: is raised if an empty list of names is provided to the
constructor.
"""

# Ensure that at least one FQDN was provided
# TODO: Do some validation on any provided names
if not names:
raise ValueError("Must provide at least one name for the certificate")

# Create a list of x509.DNSName objects from the list of FQDNs provided
self.names = names
# Create a list of x509IPAdress objects from the list of IPv4Addresses
self.ips = ips
# Initialise some values
self.key_size = key_size
self.validity = validity
self.cert = None
self.key = None
# Generate the certificate
self._generate()

def _generate(self) -> None:
"""Generate a self-signed certificate."""

_binary = Path(__file__).parent / "gen-certificate.sh"
_args: List[str] = [
"--names",
",".join(self.names),
"--ips",
",".join(map(str, self.ips)),
"--keysize",
str(self.key_size),
"--days",
str(self.validity),
]
check_call([_binary, *_args])
self.ca = Path("/tmp/ca.crt").read_bytes()
self.cert = Path("/tmp/server.crt").read_bytes()
self.key = Path("/tmp/server.key").read_bytes()

@staticmethod
def validate_cert_date(in_crt: bytes) -> bool:
with NamedTemporaryFile(mode="w+b") as crt:
crt.write(in_crt)
crt.flush()
try:
cmd = f"openssl x509 -in {crt.name} -noout -dates".split()
dates = check_output(cmd, text=True)
except CalledProcessError:
return False
before, after = [
datetime.strptime(_.split("=")[1], "%b %d %H:%M:%S %Y %Z") for _ in dates.splitlines()
]
now = datetime.utcnow()
return before <= now <= after

@staticmethod
def sans_from_cert(in_crt: bytes) -> List[str]:
with NamedTemporaryFile(mode="w+b") as crt:
crt.write(in_crt)
crt.flush()
try:
cmd = f"openssl x509 -in {crt.name} -noout -ext subjectAltName".split()
output = check_output(cmd, text=True)
except CalledProcessError:
return []
return [
dns.split(":", 1)[1]
for _ in output.splitlines()
if "DNS:" in _
for dns in _.split(", ")
]
114 changes: 114 additions & 0 deletions lib/charms/kubernetes_dashboard/v1/gen-certificate.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
#!/bin/bash
set -e

usage() {
cat <<EOF
Generate certificate suitable for use with a service.
This script uses k8s' CertificateSigningRequest API to generate a
certificate signed by k8s CA suitable for use with webhook
services. This requires permissions to create and approve CSR. See
https://kubernetes.io/docs/tasks/tls/managing-tls-in-a-cluster for
detailed explanation and additional instructions.
usage: ${0} [OPTIONS]
The following flags are required.
--names Comma separated list of dns names to associate with cert
--ips Comma separated list of ips to associate with cert Default: Empty Set
--namespace Namespace where webhook service resides. Default: 'default'
--keysize a bit length of at least 2048 when using RSA. Default: 2048
--days Period in days the certificate is valid for. Default: 3650
EOF
exit 0
}

while [[ $# -gt 0 ]]; do
case ${1} in
--namespace)
NAMESPACE="$2"
shift
;;
--keysize)
KEYSIZE="$2"
shift
;;
--days)
DAYS="$2"
shift
;;
--ips)
IPS=(${2//,/ })
shift
;;
--names)
NAMES=(${2//,/ })
shift
;;
*)
usage
;;
esac
shift
done

if [ ${#NAMES[@]} -eq 0 ]; then
echo "'--names' must be specified"
exit 1
fi

[[ ${#IPS[@]} -eq 0 ]] && IPS=()
[[ -z ${KEYSIZE} ]] && KEYSIZE=2048
[[ -z ${DAYS} ]] && DAYS=3650

if [[ ! -x "$(command -v openssl)" ]]; then
echo "openssl not found"
exit 1
fi

CERTDIR=/tmp

function createCerts() {
echo "creating certs in dir ${CERTDIR} "

cat <<EOF > ${CERTDIR}/csr.conf
[req]
default_bits = ${KEYSIZE}
distinguished_name = req_distinguished_name
req_extensions = req_ext
x509_extentions = v3_req
[req_distinguished_name]
[req_ext]
subjectAltName = @alt_names
[ v3_req ]
basicConstraints = CA:FALSE
keyUsage = digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth, clientAuth
subjectAltName = @alt_names
[alt_names]
EOF

length=${#NAMES[@]}
for (( i=0; i<${length}; i++)); do
echo "DNS.$(($i+1)) = ${NAMES[$i]}" >> ${CERTDIR}/csr.conf
done

length=${#IPS[@]}
for (( j=0; j<${length}; j++)); do
echo "DNS.$(($j+$i+1)) = ${IPS[$j]}" >> ${CERTDIR}/csr.conf
done

openssl genrsa -out ${CERTDIR}/ca.key ${KEYSIZE}
openssl req -x509 -new -nodes -key ${CERTDIR}/ca.key -subj "/CN=${NAMES[0]}" -days ${DAYS} -out ${CERTDIR}/ca.crt

openssl genrsa -out ${CERTDIR}/server.key ${KEYSIZE}
openssl req -new -key ${CERTDIR}/server.key -subj "/CN=${NAMES[0]}" -out ${CERTDIR}/server.csr -config ${CERTDIR}/csr.conf

openssl x509 -req -in ${CERTDIR}/server.csr -CA ${CERTDIR}/ca.crt -CAkey ${CERTDIR}/ca.key \
-CAcreateserial -out ${CERTDIR}/server.crt \
-extensions v3_req -extfile ${CERTDIR}/csr.conf -days ${DAYS}
}

createCerts
1 change: 0 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
ops==2.2.0
cryptography==40.0.1
lightkube==0.12.0
lightkube-models==1.26.0.4
Jinja2==3.1.2
28 changes: 14 additions & 14 deletions src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,15 @@

"""Charmed Operator for the Official Kubernetes Dashboard."""

import datetime
import logging
import traceback
from glob import glob
from ipaddress import IPv4Address
from subprocess import check_output
from typing import List, Optional

from charms.kubernetes_dashboard.v0.cert import SelfSignedCert
from charms.kubernetes_dashboard.v1.cert import SelfSignedCert
from charms.observability_libs.v0.kubernetes_service_patch import KubernetesServicePatch
from cryptography import x509
from cryptography.x509.base import Certificate
from lightkube import Client, codecs
from lightkube.core.exceptions import ApiError
from lightkube.models.core_v1 import EmptyDirVolumeSource, Volume, VolumeMount
Expand Down Expand Up @@ -136,8 +133,7 @@ def _configure_tls_certs(self) -> None:
# Pull the tls.crt file from the workload container
cert_bytes = container.pull("/certs/tls.crt")
# Create an x509 Certificate object with the contents of the file
c = x509.load_pem_x509_certificate(bytes(cert_bytes.read(), encoding="utf-8"))
if self._validate_certificate(c):
if self._validate_certificate(bytes(cert_bytes.read(), encoding="utf-8")):
return

# If we get this far, the cert is either not present, or invalid
Expand Down Expand Up @@ -191,16 +187,20 @@ def _create_kubernetes_resources(self) -> bool:
raise
return True

def _validate_certificate(self, c: Certificate) -> bool:
def _validate_certificate(self, crt: bytes) -> bool:
"""Ensure a given certificate contains the correct SANs and is valid temporally."""
# Get the list of IP Addresses in the SAN field
cert_san_ips = c.extensions.get_extension_for_class(
x509.SubjectAlternativeName
).value.get_values_for_type(x509.IPAddress)
# Ensure the certificate date is valie
if not SelfSignedCert.validate_cert_date(crt):
logger.info("Certificate has an invalid date.")
return False

# If the cert is valid and pod IP is already in the cert, we're good
if self._pod_ip in cert_san_ips and c.not_valid_after >= datetime.datetime.utcnow():
return True
return False
sans = SelfSignedCert.sans_from_cert(crt)
if str(self._pod_ip) not in sans:
logger.info(f"Pod IP {self._pod_ip} isn't present as a sans record {sans}.")
return False

return True

@property
def _dashboard_volumes(self) -> List[Volume]:
Expand Down
Loading

0 comments on commit 2d1284a

Please sign in to comment.