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