From 0992f792bc04b5151b7ede0b5cfb5d89a0656d8c Mon Sep 17 00:00:00 2001 From: Liam Young Date: Mon, 18 Jun 2018 15:24:21 +0000 Subject: [PATCH] Add vault certificates support Add support for swift-proxy to get key and certs from vault. This includes a charm helper sync. Change-Id: I2716321577439de6ca14782733ded45a7f3978a3 --- charmhelpers/contrib/hahelpers/cluster.py | 5 + .../contrib/openstack/amulet/utils.py | 12 +- charmhelpers/contrib/openstack/cert_utils.py | 227 ++++++++++++++++++ charmhelpers/contrib/openstack/context.py | 73 ++++-- charmhelpers/contrib/openstack/ip.py | 10 + charmhelpers/core/hookenv.py | 7 + hooks/certificates-relation-changed | 1 + hooks/certificates-relation-departed | 1 + hooks/certificates-relation-joined | 1 + hooks/swift_hooks.py | 18 ++ metadata.yaml | 2 + .../charmhelpers/contrib/amulet/deployment.py | 6 +- .../contrib/openstack/amulet/utils.py | 12 +- tests/charmhelpers/core/hookenv.py | 7 + 14 files changed, 344 insertions(+), 38 deletions(-) create mode 100644 charmhelpers/contrib/openstack/cert_utils.py create mode 120000 hooks/certificates-relation-changed create mode 120000 hooks/certificates-relation-departed create mode 120000 hooks/certificates-relation-joined diff --git a/charmhelpers/contrib/hahelpers/cluster.py b/charmhelpers/contrib/hahelpers/cluster.py index 47facd9..4a737e2 100644 --- a/charmhelpers/contrib/hahelpers/cluster.py +++ b/charmhelpers/contrib/hahelpers/cluster.py @@ -223,6 +223,11 @@ def https(): return True if config_get('ssl_cert') and config_get('ssl_key'): return True + for r_id in relation_ids('certificates'): + for unit in relation_list(r_id): + ca = relation_get('ca', rid=r_id, unit=unit) + if ca: + return True for r_id in relation_ids('identity-service'): for unit in relation_list(r_id): # TODO - needs fixing for new helper as ssl_cert/key suffixes with CN diff --git a/charmhelpers/contrib/openstack/amulet/utils.py b/charmhelpers/contrib/openstack/amulet/utils.py index 84e87f5..d43038b 100644 --- a/charmhelpers/contrib/openstack/amulet/utils.py +++ b/charmhelpers/contrib/openstack/amulet/utils.py @@ -40,6 +40,7 @@ import novaclient import pika import swiftclient +from charmhelpers.core.decorators import retry_on_exception from charmhelpers.contrib.amulet.utils import ( AmuletUtils ) @@ -423,6 +424,7 @@ class OpenStackAmuletUtils(AmuletUtils): self.log.debug('Checking if tenant exists ({})...'.format(tenant)) return tenant in [t.name for t in keystone.tenants.list()] + @retry_on_exception(num_retries=5, base_delay=1) def keystone_wait_for_propagation(self, sentry_relation_pairs, api_version): """Iterate over list of sentry and relation tuples and verify that @@ -542,7 +544,7 @@ class OpenStackAmuletUtils(AmuletUtils): return ep def get_default_keystone_session(self, keystone_sentry, - openstack_release=None): + openstack_release=None, api_version=2): """Return a keystone session object and client object assuming standard default settings @@ -557,12 +559,12 @@ class OpenStackAmuletUtils(AmuletUtils): eyc """ self.log.debug('Authenticating keystone admin...') - api_version = 2 - client_class = keystone_client.Client # 11 => xenial_queens - if openstack_release and openstack_release >= 11: - api_version = 3 + if api_version == 3 or (openstack_release and openstack_release >= 11): client_class = keystone_client_v3.Client + api_version = 3 + else: + client_class = keystone_client.Client keystone_ip = keystone_sentry.info['public-address'] session, auth = self.get_keystone_session( keystone_ip, diff --git a/charmhelpers/contrib/openstack/cert_utils.py b/charmhelpers/contrib/openstack/cert_utils.py new file mode 100644 index 0000000..de853b5 --- /dev/null +++ b/charmhelpers/contrib/openstack/cert_utils.py @@ -0,0 +1,227 @@ +# Copyright 2014-2018 Canonical Limited. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Common python helper functions used for OpenStack charm certificats. + +import os +import json + +from charmhelpers.contrib.network.ip import ( + get_hostname, + resolve_network_cidr, +) +from charmhelpers.core.hookenv import ( + local_unit, + network_get_primary_address, + config, + relation_get, + unit_get, + NoNetworkBinding, + log, + WARNING, +) +from charmhelpers.contrib.openstack.ip import ( + ADMIN, + resolve_address, + get_vip_in_network, + INTERNAL, + PUBLIC, + ADDRESS_MAP) + +from charmhelpers.core.host import ( + mkdir, + write_file, +) + +from charmhelpers.contrib.hahelpers.apache import ( + install_ca_cert +) + + +class CertRequest(object): + + """Create a request for certificates to be generated + """ + + def __init__(self, json_encode=True): + self.entries = [] + self.hostname_entry = None + self.json_encode = json_encode + + def add_entry(self, net_type, cn, addresses): + """Add a request to the batch + + :param net_type: str netwrok space name request is for + :param cn: str Canonical Name for certificate + :param addresses: [] List of addresses to be used as SANs + """ + self.entries.append({ + 'cn': cn, + 'addresses': addresses}) + + def add_hostname_cn(self): + """Add a request for the hostname of the machine""" + ip = unit_get('private-address') + addresses = [ip] + # If a vip is being used without os-hostname config or + # network spaces then we need to ensure the local units + # cert has the approriate vip in the SAN list + vip = get_vip_in_network(resolve_network_cidr(ip)) + if vip: + addresses.append(vip) + self.hostname_entry = { + 'cn': get_hostname(ip), + 'addresses': addresses} + + def add_hostname_cn_ip(self, addresses): + """Add an address to the SAN list for the hostname request + + :param addr: [] List of address to be added + """ + for addr in addresses: + if addr not in self.hostname_entry['addresses']: + self.hostname_entry['addresses'].append(addr) + + def get_request(self): + """Generate request from the batched up entries + + """ + if self.hostname_entry: + self.entries.append(self.hostname_entry) + request = {} + for entry in self.entries: + sans = sorted(list(set(entry['addresses']))) + request[entry['cn']] = {'sans': sans} + if self.json_encode: + return {'cert_requests': json.dumps(request, sort_keys=True)} + else: + return {'cert_requests': request} + + +def get_certificate_request(json_encode=True): + """Generate a certificatee requests based on the network confioguration + + """ + req = CertRequest(json_encode=json_encode) + req.add_hostname_cn() + # Add os-hostname entries + for net_type in [INTERNAL, ADMIN, PUBLIC]: + net_config = config(ADDRESS_MAP[net_type]['override']) + try: + net_addr = resolve_address(endpoint_type=net_type) + ip = network_get_primary_address( + ADDRESS_MAP[net_type]['binding']) + addresses = [net_addr, ip] + vip = get_vip_in_network(resolve_network_cidr(ip)) + if vip: + addresses.append(vip) + if net_config: + req.add_entry( + net_type, + net_config, + addresses) + else: + # There is network address with no corresponding hostname. + # Add the ip to the hostname cert to allow for this. + req.add_hostname_cn_ip(addresses) + except NoNetworkBinding: + log("Skipping request for certificate for ip in {} space, no " + "local address found".format(net_type), WARNING) + return req.get_request() + + +def create_ip_cert_links(ssl_dir, custom_hostname_link=None): + """Create symlinks for SAN records + + :param ssl_dir: str Directory to create symlinks in + :param custom_hostname_link: str Additional link to be created + """ + hostname = get_hostname(unit_get('private-address')) + hostname_cert = os.path.join( + ssl_dir, + 'cert_{}'.format(hostname)) + hostname_key = os.path.join( + ssl_dir, + 'key_{}'.format(hostname)) + # Add links to hostname cert, used if os-hostname vars not set + for net_type in [INTERNAL, ADMIN, PUBLIC]: + try: + addr = resolve_address(endpoint_type=net_type) + cert = os.path.join(ssl_dir, 'cert_{}'.format(addr)) + key = os.path.join(ssl_dir, 'key_{}'.format(addr)) + if os.path.isfile(hostname_cert) and not os.path.isfile(cert): + os.symlink(hostname_cert, cert) + os.symlink(hostname_key, key) + except NoNetworkBinding: + log("Skipping creating cert symlink for ip in {} space, no " + "local address found".format(net_type), WARNING) + if custom_hostname_link: + custom_cert = os.path.join( + ssl_dir, + 'cert_{}'.format(custom_hostname_link)) + custom_key = os.path.join( + ssl_dir, + 'key_{}'.format(custom_hostname_link)) + if os.path.isfile(hostname_cert) and not os.path.isfile(custom_cert): + os.symlink(hostname_cert, custom_cert) + os.symlink(hostname_key, custom_key) + + +def install_certs(ssl_dir, certs, chain=None): + """Install the certs passed into the ssl dir and append the chain if + provided. + + :param ssl_dir: str Directory to create symlinks in + :param certs: {} {'cn': {'cert': 'CERT', 'key': 'KEY'}} + :param chain: str Chain to be appended to certs + """ + for cn, bundle in certs.items(): + cert_filename = 'cert_{}'.format(cn) + key_filename = 'key_{}'.format(cn) + cert_data = bundle['cert'] + if chain: + # Append chain file so that clients that trust the root CA will + # trust certs signed by an intermediate in the chain + cert_data = cert_data + chain + write_file( + path=os.path.join(ssl_dir, cert_filename), + content=cert_data, perms=0o640) + write_file( + path=os.path.join(ssl_dir, key_filename), + content=bundle['key'], perms=0o640) + + +def process_certificates(service_name, relation_id, unit, + custom_hostname_link=None): + """Process the certificates supplied down the relation + + :param service_name: str Name of service the certifcates are for. + :param relation_id: str Relation id providing the certs + :param unit: str Unit providing the certs + :param custom_hostname_link: str Name of custom link to create + """ + data = relation_get(rid=relation_id, unit=unit) + ssl_dir = os.path.join('/etc/apache2/ssl/', service_name) + mkdir(path=ssl_dir) + name = local_unit().replace('/', '_') + certs = data.get('{}.processed_requests'.format(name)) + chain = data.get('chain') + ca = data.get('ca') + if certs: + certs = json.loads(certs) + install_ca_cert(ca.encode()) + install_certs(ssl_dir, certs, chain) + create_ip_cert_links( + ssl_dir, + custom_hostname_link=custom_hostname_link) diff --git a/charmhelpers/contrib/openstack/context.py b/charmhelpers/contrib/openstack/context.py index 2d91f0a..f3741b0 100644 --- a/charmhelpers/contrib/openstack/context.py +++ b/charmhelpers/contrib/openstack/context.py @@ -190,8 +190,8 @@ class OSContextGenerator(object): class SharedDBContext(OSContextGenerator): interfaces = ['shared-db'] - def __init__(self, - database=None, user=None, relation_prefix=None, ssl_dir=None): + def __init__(self, database=None, user=None, relation_prefix=None, + ssl_dir=None, relation_id=None): """Allows inspecting relation for settings prefixed with relation_prefix. This is useful for parsing access for multiple databases returned via the shared-db interface (eg, nova_password, @@ -202,6 +202,7 @@ class SharedDBContext(OSContextGenerator): self.user = user self.ssl_dir = ssl_dir self.rel_name = self.interfaces[0] + self.relation_id = relation_id def __call__(self): self.database = self.database or config('database') @@ -235,7 +236,12 @@ class SharedDBContext(OSContextGenerator): if self.relation_prefix: password_setting = self.relation_prefix + '_password' - for rid in relation_ids(self.interfaces[0]): + if self.relation_id: + rids = [self.relation_id] + else: + rids = relation_ids(self.interfaces[0]) + + for rid in rids: self.related = True for unit in related_units(rid): rdata = relation_get(rid=rid, unit=unit) @@ -448,11 +454,13 @@ class IdentityCredentialsContext(IdentityServiceContext): class AMQPContext(OSContextGenerator): - def __init__(self, ssl_dir=None, rel_name='amqp', relation_prefix=None): + def __init__(self, ssl_dir=None, rel_name='amqp', relation_prefix=None, + relation_id=None): self.ssl_dir = ssl_dir self.rel_name = rel_name self.relation_prefix = relation_prefix self.interfaces = [rel_name] + self.relation_id = relation_id def __call__(self): log('Generating template context for amqp', level=DEBUG) @@ -473,7 +481,11 @@ class AMQPContext(OSContextGenerator): raise OSContextError ctxt = {} - for rid in relation_ids(self.rel_name): + if self.relation_id: + rids = [self.relation_id] + else: + rids = relation_ids(self.rel_name) + for rid in rids: ha_vip_only = False self.related = True transport_hosts = None @@ -789,17 +801,18 @@ class ApacheSSLContext(OSContextGenerator): ssl_dir = os.path.join('/etc/apache2/ssl/', self.service_namespace) mkdir(path=ssl_dir) cert, key = get_cert(cn) - if cn: - cert_filename = 'cert_{}'.format(cn) - key_filename = 'key_{}'.format(cn) - else: - cert_filename = 'cert' - key_filename = 'key' + if cert and key: + if cn: + cert_filename = 'cert_{}'.format(cn) + key_filename = 'key_{}'.format(cn) + else: + cert_filename = 'cert' + key_filename = 'key' - write_file(path=os.path.join(ssl_dir, cert_filename), - content=b64decode(cert), perms=0o640) - write_file(path=os.path.join(ssl_dir, key_filename), - content=b64decode(key), perms=0o640) + write_file(path=os.path.join(ssl_dir, cert_filename), + content=b64decode(cert), perms=0o640) + write_file(path=os.path.join(ssl_dir, key_filename), + content=b64decode(key), perms=0o640) def configure_ca(self): ca_cert = get_ca_cert() @@ -871,23 +884,31 @@ class ApacheSSLContext(OSContextGenerator): if not self.external_ports or not https(): return {} - self.configure_ca() + use_keystone_ca = True + for rid in relation_ids('certificates'): + if related_units(rid): + use_keystone_ca = False + + if use_keystone_ca: + self.configure_ca() + self.enable_modules() ctxt = {'namespace': self.service_namespace, 'endpoints': [], 'ext_ports': []} - cns = self.canonical_names() - if cns: - for cn in cns: - self.configure_cert(cn) - else: - # Expect cert/key provided in config (currently assumed that ca - # uses ip for cn) - for net_type in (INTERNAL, ADMIN, PUBLIC): - cn = resolve_address(endpoint_type=net_type) - self.configure_cert(cn) + if use_keystone_ca: + cns = self.canonical_names() + if cns: + for cn in cns: + self.configure_cert(cn) + else: + # Expect cert/key provided in config (currently assumed that ca + # uses ip for cn) + for net_type in (INTERNAL, ADMIN, PUBLIC): + cn = resolve_address(endpoint_type=net_type) + self.configure_cert(cn) addresses = self.get_network_addresses() for address, endpoint in addresses: diff --git a/charmhelpers/contrib/openstack/ip.py b/charmhelpers/contrib/openstack/ip.py index d1476b1..73102af 100644 --- a/charmhelpers/contrib/openstack/ip.py +++ b/charmhelpers/contrib/openstack/ip.py @@ -184,3 +184,13 @@ def resolve_address(endpoint_type=PUBLIC, override=True): "clustered=%s)" % (net_type, clustered)) return resolved_address + + +def get_vip_in_network(network): + matching_vip = None + vips = config('vip') + if vips: + for vip in vips.split(): + if is_address_in_network(network, vip): + matching_vip = vip + return matching_vip diff --git a/charmhelpers/core/hookenv.py b/charmhelpers/core/hookenv.py index 627d8f7..ed7af39 100644 --- a/charmhelpers/core/hookenv.py +++ b/charmhelpers/core/hookenv.py @@ -972,6 +972,13 @@ def application_version_set(version): log("Application Version: {}".format(version)) +@translate_exc(from_exc=OSError, to_exc=NotImplementedError) +def goal_state(): + """Juju goal state values""" + cmd = ['goal-state', '--format=json'] + return json.loads(subprocess.check_output(cmd).decode('UTF-8')) + + @translate_exc(from_exc=OSError, to_exc=NotImplementedError) def is_leader(): """Does the current unit hold the juju leadership diff --git a/hooks/certificates-relation-changed b/hooks/certificates-relation-changed new file mode 120000 index 0000000..8623fba --- /dev/null +++ b/hooks/certificates-relation-changed @@ -0,0 +1 @@ +swift_hooks.py \ No newline at end of file diff --git a/hooks/certificates-relation-departed b/hooks/certificates-relation-departed new file mode 120000 index 0000000..8623fba --- /dev/null +++ b/hooks/certificates-relation-departed @@ -0,0 +1 @@ +swift_hooks.py \ No newline at end of file diff --git a/hooks/certificates-relation-joined b/hooks/certificates-relation-joined new file mode 120000 index 0000000..8623fba --- /dev/null +++ b/hooks/certificates-relation-joined @@ -0,0 +1 @@ +swift_hooks.py \ No newline at end of file diff --git a/hooks/swift_hooks.py b/hooks/swift_hooks.py index 8a42528..05c3978 100755 --- a/hooks/swift_hooks.py +++ b/hooks/swift_hooks.py @@ -122,6 +122,10 @@ from charmhelpers.contrib.network.ip import ( from charmhelpers.contrib.openstack.context import ADDRESS_TYPES from charmhelpers.contrib.charmsupport import nrpe from charmhelpers.contrib.hardening.harden import harden +from charmhelpers.contrib.openstack.cert_utils import ( + get_certificate_request, + process_certificates, +) extra_pkgs = [ "haproxy", @@ -772,6 +776,20 @@ def amqp_changed(): CONFIGS.write_all() +@hooks.hook('certificates-relation-joined') +def certs_joined(relation_id=None): + relation_set( + relation_id=relation_id, + relation_settings=get_certificate_request()) + + +@hooks.hook('certificates-relation-changed') +@restart_on_change(restart_map(), stopstart=True) +def certs_changed(): + process_certificates('swift', None, None) + configure_https() + + def main(): try: hooks.execute(sys.argv) diff --git a/metadata.yaml b/metadata.yaml index 135dbb1..863dc8f 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -42,6 +42,8 @@ requires: scope: container amqp: interface: rabbitmq + certificates: + interface: tls-certificates peers: cluster: interface: swift-ha diff --git a/tests/charmhelpers/contrib/amulet/deployment.py b/tests/charmhelpers/contrib/amulet/deployment.py index 9c65518..d21d01d 100644 --- a/tests/charmhelpers/contrib/amulet/deployment.py +++ b/tests/charmhelpers/contrib/amulet/deployment.py @@ -50,7 +50,8 @@ class AmuletDeployment(object): this_service['units'] = 1 self.d.add(this_service['name'], units=this_service['units'], - constraints=this_service.get('constraints')) + constraints=this_service.get('constraints'), + storage=this_service.get('storage')) for svc in other_services: if 'location' in svc: @@ -64,7 +65,8 @@ class AmuletDeployment(object): svc['units'] = 1 self.d.add(svc['name'], charm=branch_location, units=svc['units'], - constraints=svc.get('constraints')) + constraints=svc.get('constraints'), + storage=svc.get('storage')) def _add_relations(self, relations): """Add all of the relations for the services.""" diff --git a/tests/charmhelpers/contrib/openstack/amulet/utils.py b/tests/charmhelpers/contrib/openstack/amulet/utils.py index 84e87f5..d43038b 100644 --- a/tests/charmhelpers/contrib/openstack/amulet/utils.py +++ b/tests/charmhelpers/contrib/openstack/amulet/utils.py @@ -40,6 +40,7 @@ import novaclient import pika import swiftclient +from charmhelpers.core.decorators import retry_on_exception from charmhelpers.contrib.amulet.utils import ( AmuletUtils ) @@ -423,6 +424,7 @@ class OpenStackAmuletUtils(AmuletUtils): self.log.debug('Checking if tenant exists ({})...'.format(tenant)) return tenant in [t.name for t in keystone.tenants.list()] + @retry_on_exception(num_retries=5, base_delay=1) def keystone_wait_for_propagation(self, sentry_relation_pairs, api_version): """Iterate over list of sentry and relation tuples and verify that @@ -542,7 +544,7 @@ class OpenStackAmuletUtils(AmuletUtils): return ep def get_default_keystone_session(self, keystone_sentry, - openstack_release=None): + openstack_release=None, api_version=2): """Return a keystone session object and client object assuming standard default settings @@ -557,12 +559,12 @@ class OpenStackAmuletUtils(AmuletUtils): eyc """ self.log.debug('Authenticating keystone admin...') - api_version = 2 - client_class = keystone_client.Client # 11 => xenial_queens - if openstack_release and openstack_release >= 11: - api_version = 3 + if api_version == 3 or (openstack_release and openstack_release >= 11): client_class = keystone_client_v3.Client + api_version = 3 + else: + client_class = keystone_client.Client keystone_ip = keystone_sentry.info['public-address'] session, auth = self.get_keystone_session( keystone_ip, diff --git a/tests/charmhelpers/core/hookenv.py b/tests/charmhelpers/core/hookenv.py index 627d8f7..ed7af39 100644 --- a/tests/charmhelpers/core/hookenv.py +++ b/tests/charmhelpers/core/hookenv.py @@ -972,6 +972,13 @@ def application_version_set(version): log("Application Version: {}".format(version)) +@translate_exc(from_exc=OSError, to_exc=NotImplementedError) +def goal_state(): + """Juju goal state values""" + cmd = ['goal-state', '--format=json'] + return json.loads(subprocess.check_output(cmd).decode('UTF-8')) + + @translate_exc(from_exc=OSError, to_exc=NotImplementedError) def is_leader(): """Does the current unit hold the juju leadership