Charmed-Kubernetes/easyrsa/reactive/easyrsa.py

487 lines
19 KiB
Python

import os
import shutil
from shlex import split
from subprocess import check_call
from subprocess import check_output
from charms.reactive import hook
from charms.reactive import when
from charms.reactive import when_not
from charms.reactive.helpers import data_changed
from charms.reactive.relations import endpoint_from_flag
from charms.reactive.flags import is_flag_set
from charms.reactive.flags import clear_flag
from charms.reactive.flags import set_flag
from charmhelpers.core import hookenv
from charmhelpers.core import unitdata
from charmhelpers.core.host import chdir
from charmhelpers.core.hookenv import resource_get
from charms.leadership import leader_set
from charms.leadership import leader_get
from charms.layer import status
charm_directory = hookenv.charm_dir()
easyrsa_directory = os.path.join(charm_directory, 'EasyRSA')
@when_not('easyrsa.installed')
def install():
'''Install the easy-rsa software that is used by this layer.'''
easyrsa_resource = None
try:
# Try to get the resource from Juju.
easyrsa_resource = resource_get('easyrsa')
except Exception as e:
message = 'An error occurred fetching the easyrsa resource.'
hookenv.log(message)
hookenv.log(e)
status.blocked(message)
return
if not easyrsa_resource:
status.blocked('The easyrsa resource is missing.')
return
# Get the filesize in bytes.
filesize = os.stat(easyrsa_resource).st_size
# When the filesize is less than 10 KB we do not have a real file.
if filesize < 10240:
status.blocked('The easyrsa resource is not complete.')
return
# Expand the archive in the charm directory creating an EasyRSA directory.
untar = 'tar -xvzf {0} -C {1}'.format(easyrsa_resource, charm_directory)
check_call(split(untar))
version = get_version(easyrsa_resource)
# Save the version in the key/value store of the charm.
unitdata.kv().set('easyrsa-version', version)
if os.path.islink(easyrsa_directory):
check_call(split('rm -v {0}'.format(easyrsa_directory)))
# Link the EasyRSA version directory to a common name.
link = 'ln -v -s {0}/EasyRSA-{1} {2}'.format(charm_directory,
version,
easyrsa_directory)
check_call(split(link))
# The charm pki directory contains backup of pki for upgrades.
charm_pki_directory = os.path.join(charm_directory, 'pki')
if os.path.isdir(charm_pki_directory):
new_pki_directory = os.path.join(easyrsa_directory, 'pki')
# Only copy the directory if the new_pki_directory does not exist.
if not os.path.isdir(new_pki_directory):
# Copy the pki to this new directory.
shutil.copytree(charm_pki_directory, new_pki_directory,
symlinks=True)
# We are done with the old charm pki directory, so delete contents.
shutil.rmtree(charm_pki_directory)
else:
# Create new pki.
with chdir(easyrsa_directory):
check_call(split('./easyrsa --batch init-pki 2>&1'))
set_flag('easyrsa.installed')
@when('easyrsa.installed')
def set_easyrsa_version():
'''Find the version of easyrsa and set that on the charm.'''
version = unitdata.kv().get('easyrsa-version')
hookenv.application_version_set(version)
@when('easyrsa.installed')
@when_not('easyrsa.configured')
def configure_easyrsa():
'''A transitional state to allow modifications to configuration before
generating the certificates and working with PKI.'''
hookenv.log('Configuring OpenSSL to copy extensions.')
configure_copy_extensions()
hookenv.log('Configuring X509 server extensions with clientAuth.')
configure_client_authorization()
set_flag('easyrsa.configured')
def configure_copy_extensions():
'''Update the EasyRSA configuration with the capacity to copy the exensions
through to the resulting certificates. '''
# Create an absolute path to the file which will not be impacted by cwd.
openssl_file = os.path.join(easyrsa_directory, 'openssl-1.0.cnf')
# Update EasyRSA configuration with the capacity to copy CSR Requested
# Extensions through to the resulting certificate. This can be tricky,
# and the implications are not fully clear on this.
with open(openssl_file, 'r') as f:
conf = f.readlines()
# When the copy_extensions key is not in the configuration.
if 'copy_extensions = copy\n' not in conf:
for idx, line in enumerate(conf):
if '[ CA_default ]' in line:
# Insert a new line with the copy_extensions key set to copy.
conf.insert(idx + 1, "copy_extensions = copy\n")
with open(openssl_file, 'w+') as f:
f.writelines(conf)
def configure_client_authorization():
'''easyrsa has a default OpenSSL configuration that does not support
client authentication. Append "clientAuth" to the server ssl certificate
configuration. This is not default, to enable this in your charm set the
reactive state 'tls.client.authorization.required'.
'''
# Use an absolute path so current directory does not affect the result.
openssl_config = os.path.join(easyrsa_directory, 'x509-types/server')
hookenv.log('Updating {0}'.format(openssl_config))
# Read the X509 server extension file in.
with open(openssl_config, 'r') as f:
server_extensions = f.readlines()
client_server = []
for line in server_extensions:
# Replace the extendedKeyUsage with clientAuth and serverAuth.
if 'extendedKeyUsage' in line:
line = line.replace('extendedKeyUsage = serverAuth',
'extendedKeyUsage = clientAuth, serverAuth')
client_server.append(line)
# Write the configuration file back out.
with open(openssl_config, 'w+') as f:
f.writelines(client_server)
@when('easyrsa.configured')
@when('leadership.is_leader')
@when_not('easyrsa.certificate.authority.available')
@when_not('upgrade.series.in-progress')
def create_certificate_authority():
'''Return the CA and server certificates for this system. If the CA is
empty, generate a self signged certificate authority.'''
ca_file = 'pki/ca.crt'
key_file = 'pki/private/ca.key'
serial_file = 'pki/serial'
with chdir(easyrsa_directory):
if leader_get('certificate_authority') and \
leader_get('certificate_authority_key') and \
leader_get('certificate_authority_serial'):
hookenv.log('Recovering CA from controller')
certificate_authority = \
leader_get('certificate_authority')
certificate_authority_key = \
leader_get('certificate_authority_key')
certificate_authority_serial = \
leader_get('certificate_authority_serial')
# Write the CA from existing relation.
with open(ca_file, 'w') as f_out:
f_out.write(certificate_authority)
# Write the private key from existing relation.
with open(key_file, 'w') as f_out:
f_out.write(certificate_authority_key)
# Write the serial from existing relation.
with open(serial_file, 'w') as f_out:
f_out.write(certificate_authority_serial)
# Bluff required files and folders.
with open('pki/index.txt', 'w') as f_out:
pass
os.makedirs('pki/issued')
os.makedirs('pki/certs_by_serial')
else:
hookenv.log('Creating new CA')
# The Common Name (CN) for a certificate
# must be an IP or hostname.
cn = hookenv.unit_public_ip()
# Create a self signed CA with the CN, stored pki/ca.crt
build_ca = \
'./easyrsa --batch "--req-cn={0}" build-ca nopass 2>&1'
# Build a self signed Certificate Authority.
check_call(split(build_ca.format(cn)))
# Read the CA so it can be returned in leader data.
with open(ca_file, 'r') as stream:
certificate_authority = stream.read()
# Read the private key so it can be set in leader data.
with open(key_file, 'r') as stream:
certificate_authority_key = stream.read()
with open(serial_file, 'r') as stream:
certificate_authority_serial = stream.read()
# Set these values on the leadership data.
leader_set({
'certificate_authority': certificate_authority})
leader_set({
'certificate_authority_key': certificate_authority_key})
leader_set({
'certificate_authority_serial': certificate_authority_serial})
# Install the CA on this system as a trusted CA.
install_ca(certificate_authority)
status.active('Certificiate Authority available')
set_flag('easyrsa.certificate.authority.available')
@when('easyrsa.certificate.authority.available')
@when_not('upgrade.series.in-progress')
def message():
'''Set a message to notify the user that this charm is ready.'''
if is_flag_set('client.available'):
status.active('Certificate Authority connected.')
else:
status.active('Certificate Authority ready.')
@when('client.available', 'easyrsa.certificate.authority.available')
@when('leadership.is_leader')
def send_ca():
'''The client relationship has been established, read the CA and client
certificate from leadership data to set them on the relationship object.'''
tls = endpoint_from_flag('client.available')
certificate_authority = leader_get('certificate_authority')
tls.set_ca(certificate_authority)
@when('leadership.is_leader',
'easyrsa.certificate.authority.available',
'client.available')
@when_not('easyrsa.global-client-cert.created')
def create_global_client_cert():
"""
This is for backwards compatibility with older tls-certificate clients
only. Obviously, it's not good security / design to have clients sharing
a certificate, but it seems that there are clients that depend on this
(though some, like etcd, only block on the flag that it triggers but don't
actually use the cert), so we have to set it for now.
"""
client_cert = leader_get('client_certificate')
client_key = leader_get('client_key')
if not client_cert or not client_key:
hookenv.log("Unable to find global client cert on "
"leadership data, generating...")
client_cert, client_key = create_client_certificate()
# Set the client certificate and key on leadership data.
leader_set({'client_certificate': client_cert})
leader_set({'client_key': client_key})
else:
hookenv.log("found global client cert on leadership "
"data, not generating...")
set_flag('easyrsa.global-client-cert.created')
@when('leadership.is_leader',
'easyrsa.global-client-cert.created',
'client.available')
def publish_global_client_cert():
# global client cert needs to always be re-published to account for new
# clients joining
tls = endpoint_from_flag('client.available')
tls.set_client_cert(leader_get('client_certificate'),
leader_get('client_key'))
@when('client.server.certs.requested', 'easyrsa.configured')
def create_server_cert():
'''Create server certificates with the request information from the
relation object.'''
tls = endpoint_from_flag('client.server.certs.requested')
# Iterate over all new requests
for request in tls.new_server_requests:
cn = request.common_name
sans = request.sans
name = request.common_name
# Create the server certificate based on the information in request.
server_cert, server_key = create_server_certificate(cn, sans, name)
# Set the certificate and key for the unit on the relationship object.
request.set_cert(server_cert, server_key)
@when('client.client.certs.requested', 'easyrsa.configured')
def create_client_cert():
'''Create client certificates with the request information from the
relation object.'''
tls = endpoint_from_flag('client.client.certs.requested')
# Iterate over all new requests
for request in tls.new_client_requests:
# Create a client certificate for this request.
name = request.common_name
client_cert, client_key = create_client_certificate(name)
# Set the client certificate and key on the relationship object.
request.set_cert(client_cert, client_key)
@hook('upgrade-charm')
def upgrade():
'''An upgrade has been triggered.'''
pki_directory = os.path.join(easyrsa_directory, 'pki')
if os.path.isdir(pki_directory):
# specific handling if the upgrade is from a previous version
# where certificate_authority_serial is not set at install
serial_file = 'serial'
with chdir(pki_directory):
# if the ca and ca_key are set and serial is not
# set this to serial in the pki directory
if os.path.isfile(serial_file) and \
leader_get('certificate_authority') and \
leader_get('certificate_authority_key') and not \
leader_get('certificate_authority_serial'):
with open(serial_file, 'r') as stream:
ca_serial = stream.read()
# set the previously unset certificate authority serial
leader_set({
'certificate_authority_serial': ca_serial})
charm_pki_directory = os.path.join(charm_directory, 'pki')
# When the charm pki directory exists, it is stale, remove it.
if os.path.isdir(charm_pki_directory):
shutil.rmtree(charm_pki_directory)
# Copy the EasyRSA/pki to the charm pki directory.
shutil.copytree(pki_directory, charm_pki_directory, symlinks=True)
clear_flag('easyrsa.installed')
clear_flag('easyrsa.configured')
@hook('pre-series-upgrade')
def pre_series_upgrade():
status.blocked('Series upgrade in progress')
def remove_file_if_exists(filename):
try:
os.remove(filename)
except FileNotFoundError:
pass
def create_server_certificate(cn, san_list, name=None):
'''Return a newly created server certificate and server key from a
common name, list of Subject Alternate Names, and the certificate name.'''
if name is None:
name = 'server'
server_cert = None
server_key = None
with chdir(easyrsa_directory):
# Create the path to the server certificate.
cert_file = 'pki/issued/{0}.crt'.format(name)
# Create the path to the server key.
key_file = 'pki/private/{0}.key'.format(name)
# Create the path to the request file
req_file = 'pki/reqs/{0}.req'.format(name)
# Get a string compatible with easyrsa for the subject-alt-names.
sans = get_sans(san_list)
sans_arg = '--subject-alt-name={}'.format(sans) if sans else ''
this_cert = {'sans': sans, 'cn': cn, 'name': name}
changed = data_changed('server_cert.' + name, this_cert)
cert_exists = os.path.isfile(cert_file) and os.path.isfile(key_file)
# Do not regenerate the server certificate if it already exists
# and the data hasn't changed.
if changed and cert_exists:
# We need to revoke the existing cert and regenerate it
revoke = './easyrsa --batch revoke {0}'.format(name)
check_call(split(revoke))
# nuke old files if they exist
remove_file_if_exists(cert_file)
remove_file_if_exists(key_file)
remove_file_if_exists(req_file)
if changed or not cert_exists:
# Create a server certificate for the server based on the CN.
server = './easyrsa --batch --req-cn={0} {1} ' \
'build-server-full {2} nopass 2>&1'.format(cn,
sans_arg,
name)
check_call(split(server))
# Read the server certificate from the file system.
with open(cert_file, 'r') as stream:
server_cert = stream.read()
# Read the server key from the file system.
with open(key_file, 'r') as stream:
server_key = stream.read()
return server_cert, server_key
def create_client_certificate(name='client'):
'''Return a newly created client certificate and client key, by name.'''
client_cert = None
client_key = None
with chdir(easyrsa_directory):
# Create a path to the client certificate.
cert_file = 'pki/issued/{0}.crt'.format(name)
# Create a path to the client key.
key_file = 'pki/private/{0}.key'.format(name)
# Do not regenerate the client certificate if it already exists.
if not os.path.isfile(cert_file) and not os.path.isfile(key_file):
# Create a client certificate and key.
check_call(['./easyrsa', 'build-client-full', name, 'nopass'])
# Read the client certificate from the file system.
with open(cert_file, 'r') as stream:
client_cert = stream.read()
# Read the client key from the file system.
with open(key_file, 'r') as stream:
client_key = stream.read()
return client_cert, client_key
def install_ca(certificate_authority):
'''Install a certificiate authority on the system by calling the
update-ca-certificates command.'''
name = hookenv.service_name()
ca_file = '/usr/local/share/ca-certificates/{0}.crt'.format(name)
hookenv.log('Writing CA to {0}'.format(ca_file))
# Write the contents of certificate authority to the file.
with open(ca_file, 'w') as fp:
fp.write(certificate_authority)
# Update the trusted CAs on this system.
check_call(['update-ca-certificates'])
message = 'Generated ca-certificates.crt for {0}'.format(name)
hookenv.log(message)
def get_sans(address_list=[]):
'''Return a string suitable for the easy-rsa subjectAltNames.'''
sans = []
for address in address_list:
if _is_ip(address):
sans.append('IP:{0}'.format(address))
else:
sans.append('DNS:{0}'.format(address))
return ','.join(sans)
def get_version(path):
'''Return the version of EasyRSA by investigating the tar file.'''
# Create a command that lists the tar file.
cmd = 'tar -tf {0}'.format(path)
# Get the listing of the directories and files in the tar file.
output = check_output(split(cmd)).decode('utf-8')
# Get the first listing which is the directory.
directory = output.splitlines()[0]
# Remove the path separator from the string.
if '/' in directory:
directory = directory.replace('/', '')
# Get the version by splitting on the hypen.
return directory.split('-')[1]
def _is_ip(address):
'''Return True if the address is an IP address, false otherwise.'''
import ipaddress
try:
# This method will raise a ValueError if argument is not an IP address.
ipaddress.ip_address(address)
return True
except ValueError:
return False