Add support for certificates interface.
Add support for the dashboard to request and receive certificates via the certificates relation, currently implemented by vault. The first request to the dashboard can take sometime to return so increase the timeout. Change-Id: I173523ddbe3269e3fcdae49062cdb34e78786e44
This commit is contained in:
parent
4566f20b4c
commit
32d180aabf
1
hooks/certificates-relation-changed
Symbolic link
1
hooks/certificates-relation-changed
Symbolic link
@ -0,0 +1 @@
|
||||
horizon_hooks.py
|
1
hooks/certificates-relation-departed
Symbolic link
1
hooks/certificates-relation-departed
Symbolic link
@ -0,0 +1 @@
|
||||
horizon_hooks.py
|
1
hooks/certificates-relation-joined
Symbolic link
1
hooks/certificates-relation-joined
Symbolic link
@ -0,0 +1 @@
|
||||
horizon_hooks.py
|
227
hooks/charmhelpers/contrib/openstack/cert_utils.py
Normal file
227
hooks/charmhelpers/contrib/openstack/cert_utils.py
Normal file
@ -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)
|
@ -52,6 +52,9 @@ VALID_ENDPOINT_TYPES = {
|
||||
'ADMINURL': 'adminURL',
|
||||
}
|
||||
|
||||
SSL_CERT_FILE = '/etc/apache2/ssl/horizon/cert_dashboard'
|
||||
SSL_KEY_FILE = '/etc/apache2/ssl/horizon/key_dashboard'
|
||||
|
||||
|
||||
class HorizonHAProxyContext(OSContextGenerator):
|
||||
def __call__(self):
|
||||
@ -229,27 +232,37 @@ class ApacheContext(OSContextGenerator):
|
||||
class ApacheSSLContext(OSContextGenerator):
|
||||
def __call__(self):
|
||||
''' Grab cert and key from configuration for SSL config '''
|
||||
ca_cert = get_ca_cert()
|
||||
if ca_cert:
|
||||
ctxt = {'ssl_configured': False}
|
||||
use_local_ca = True
|
||||
for rid in relation_ids('certificates'):
|
||||
if related_units(rid):
|
||||
use_local_ca = False
|
||||
|
||||
if use_local_ca:
|
||||
ca_cert = get_ca_cert()
|
||||
if not ca_cert:
|
||||
return ctxt
|
||||
install_ca_cert(b64decode(ca_cert))
|
||||
|
||||
ssl_cert, ssl_key = get_cert()
|
||||
if all([ssl_cert, ssl_key]):
|
||||
with open('/etc/ssl/certs/dashboard.cert', 'w') as cert_out:
|
||||
cert_out.write(b64decode(ssl_cert))
|
||||
with open('/etc/ssl/private/dashboard.key', 'w') as key_out:
|
||||
key_out.write(b64decode(ssl_key))
|
||||
os.chmod('/etc/ssl/private/dashboard.key', 0600)
|
||||
ctxt = {
|
||||
'ssl_configured': True,
|
||||
'ssl_cert': '/etc/ssl/certs/dashboard.cert',
|
||||
'ssl_key': '/etc/ssl/private/dashboard.key',
|
||||
}
|
||||
ssl_cert, ssl_key = get_cert()
|
||||
if all([ssl_cert, ssl_key]):
|
||||
with open('/etc/ssl/certs/dashboard.cert', 'w') as cert_out:
|
||||
cert_out.write(b64decode(ssl_cert))
|
||||
with open('/etc/ssl/private/dashboard.key', 'w') as key_out:
|
||||
key_out.write(b64decode(ssl_key))
|
||||
os.chmod('/etc/ssl/private/dashboard.key', 0600)
|
||||
ctxt = {
|
||||
'ssl_configured': True,
|
||||
'ssl_cert': '/etc/ssl/certs/dashboard.cert',
|
||||
'ssl_key': '/etc/ssl/private/dashboard.key',
|
||||
}
|
||||
else:
|
||||
# Use snakeoil ones by default
|
||||
ctxt = {
|
||||
'ssl_configured': False,
|
||||
}
|
||||
if os.path.exists(SSL_CERT_FILE) and os.path.exists(SSL_KEY_FILE):
|
||||
ctxt = {
|
||||
'ssl_configured': True,
|
||||
'ssl_cert': SSL_CERT_FILE,
|
||||
'ssl_key': SSL_KEY_FILE,
|
||||
}
|
||||
return ctxt
|
||||
|
||||
|
||||
|
@ -39,6 +39,7 @@ from charmhelpers.fetch import (
|
||||
)
|
||||
from charmhelpers.core.host import (
|
||||
lsb_release,
|
||||
service_reload,
|
||||
)
|
||||
from charmhelpers.contrib.openstack.utils import (
|
||||
configure_installation_source,
|
||||
@ -72,6 +73,10 @@ from charmhelpers.contrib.network.ip import (
|
||||
is_ipv6,
|
||||
get_relation_ip,
|
||||
)
|
||||
from charmhelpers.contrib.openstack.cert_utils import (
|
||||
get_certificate_request,
|
||||
process_certificates,
|
||||
)
|
||||
from charmhelpers.contrib.hahelpers.apache import install_ca_cert
|
||||
from charmhelpers.contrib.hahelpers.cluster import get_hacluster_config
|
||||
from charmhelpers.payload.execd import execd_preinstall
|
||||
@ -155,6 +160,9 @@ def config_changed():
|
||||
check_custom_theme()
|
||||
open_port(80)
|
||||
open_port(443)
|
||||
for relid in relation_ids('certificates'):
|
||||
for unit in related_units(relid):
|
||||
certs_changed(relation_id=relid, unit=unit)
|
||||
|
||||
websso_trusted_dashboard_changed()
|
||||
|
||||
@ -397,5 +405,21 @@ def main():
|
||||
assess_status(CONFIGS)
|
||||
|
||||
|
||||
@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')
|
||||
def certs_changed(relation_id=None, unit=None):
|
||||
process_certificates('horizon', relation_id, unit,
|
||||
custom_hostname_link='dashboard')
|
||||
CONFIGS.write_all()
|
||||
service_reload('apache2')
|
||||
enable_ssl()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
@ -35,6 +35,8 @@ requires:
|
||||
interface: mysql-shared
|
||||
websso-fid-service-provider:
|
||||
interface: websso-fid-service-provider
|
||||
certificates:
|
||||
interface: tls-certificates
|
||||
peers:
|
||||
cluster:
|
||||
interface: openstack-dashboard-ha
|
||||
|
@ -36,6 +36,8 @@ from test_utils import (
|
||||
)
|
||||
|
||||
TO_PATCH = [
|
||||
'CONFIGS',
|
||||
'do_action_openstack_upgrade',
|
||||
'do_openstack_upgrade',
|
||||
'config_changed',
|
||||
]
|
||||
@ -47,28 +49,25 @@ class TestHorizonUpgradeActions(CharmTestCase):
|
||||
super(TestHorizonUpgradeActions, self).setUp(openstack_upgrade,
|
||||
TO_PATCH)
|
||||
|
||||
@patch('charmhelpers.contrib.openstack.utils.config')
|
||||
@patch('charmhelpers.contrib.openstack.utils.action_set')
|
||||
@patch('charmhelpers.contrib.openstack.utils.openstack_upgrade_available')
|
||||
def test_openstack_upgrade_true(self, upgrade_avail,
|
||||
action_set, config):
|
||||
upgrade_avail.return_value = True
|
||||
config.return_value = True
|
||||
|
||||
openstack_upgrade.openstack_upgrade()
|
||||
|
||||
self.assertTrue(self.do_openstack_upgrade.called)
|
||||
self.assertTrue(self.config_changed.called)
|
||||
|
||||
@patch('charmhelpers.contrib.openstack.utils.config')
|
||||
@patch('charmhelpers.contrib.openstack.utils.action_set')
|
||||
@patch('charmhelpers.contrib.openstack.utils.openstack_upgrade_available')
|
||||
def test_openstack_upgrade_false(self, upgrade_avail,
|
||||
action_set, config):
|
||||
upgrade_avail.return_value = True
|
||||
config.return_value = False
|
||||
def test_openstack_upgrade_true(self):
|
||||
|
||||
self.do_action_openstack_upgrade.return_value = True
|
||||
openstack_upgrade.openstack_upgrade()
|
||||
|
||||
self.do_action_openstack_upgrade.assert_called_once_with(
|
||||
'openstack-dashboard',
|
||||
self.do_openstack_upgrade,
|
||||
self.CONFIGS)
|
||||
self.config_changed.assert_called_once_with()
|
||||
|
||||
def test_openstack_upgrade_false(self):
|
||||
self.do_action_openstack_upgrade.return_value = False
|
||||
|
||||
openstack_upgrade.openstack_upgrade()
|
||||
|
||||
self.do_action_openstack_upgrade.assert_called_once_with(
|
||||
'openstack-dashboard',
|
||||
self.do_openstack_upgrade,
|
||||
self.CONFIGS)
|
||||
self.assertFalse(self.do_openstack_upgrade.called)
|
||||
self.assertFalse(self.config_changed.called)
|
||||
|
@ -95,11 +95,13 @@ class TestHorizonContexts(CharmTestCase):
|
||||
'hsts_max_age_seconds': 15768000,
|
||||
'custom_theme': False})
|
||||
|
||||
@patch.object(horizon_contexts, 'get_ca_cert', lambda: None)
|
||||
@patch.object(horizon_contexts, 'get_ca_cert', lambda: 'ca_cert')
|
||||
@patch.object(horizon_contexts, 'install_ca_cert')
|
||||
@patch('os.chmod')
|
||||
def test_ApacheSSLContext_enabled(self, _chmod):
|
||||
def test_ApacheSSLContext_enabled(self, _chmod, _install_ca_cert):
|
||||
self.relation_ids.return_value = []
|
||||
self.get_cert.return_value = ('cert', 'key')
|
||||
self.b64decode.side_effect = ['cert', 'key']
|
||||
self.b64decode.side_effect = ['ca', 'cert', 'key']
|
||||
with patch_open() as (_open, _file):
|
||||
self.assertEqual(horizon_contexts.ApacheSSLContext()(),
|
||||
{'ssl_configured': True,
|
||||
@ -115,13 +117,27 @@ class TestHorizonContexts(CharmTestCase):
|
||||
])
|
||||
# Security check on key permissions
|
||||
_chmod.assert_called_with('/etc/ssl/private/dashboard.key', 0o600)
|
||||
_install_ca_cert.assert_called_once()
|
||||
|
||||
@patch.object(horizon_contexts, 'get_ca_cert', lambda: None)
|
||||
def test_ApacheSSLContext_disabled(self):
|
||||
self.relation_ids.return_value = []
|
||||
self.get_cert.return_value = (None, None)
|
||||
self.assertEqual(horizon_contexts.ApacheSSLContext()(),
|
||||
{'ssl_configured': False})
|
||||
|
||||
@patch.object(horizon_contexts.os.path, 'exists')
|
||||
def test_ApacheSSLContext_vault(self, _exists):
|
||||
_exists.return_value = True
|
||||
self.relation_ids.return_value = ['certificates:60']
|
||||
self.related_units.return_value = ['vault/0']
|
||||
self.assertEqual(
|
||||
horizon_contexts.ApacheSSLContext()(),
|
||||
{
|
||||
'ssl_configured': True,
|
||||
'ssl_cert': '/etc/apache2/ssl/horizon/cert_dashboard',
|
||||
'ssl_key': '/etc/apache2/ssl/horizon/key_dashboard'})
|
||||
|
||||
def test_HorizonContext_defaults(self):
|
||||
self.assertEqual(horizon_contexts.HorizonContext()(),
|
||||
{'compress_offline': True, 'debug': False,
|
||||
|
@ -273,6 +273,7 @@ class TestHorizonHooks(CharmTestCase):
|
||||
'identity-service': [
|
||||
'identity/0',
|
||||
],
|
||||
'certificates': [],
|
||||
}[rname]
|
||||
self.relation_ids.side_effect = relation_ids_side_effect
|
||||
|
||||
@ -287,7 +288,6 @@ class TestHorizonHooks(CharmTestCase):
|
||||
'webroot': '/horizon',
|
||||
}[key]
|
||||
self.config.side_effect = config_side_effect
|
||||
|
||||
self.openstack_upgrade_available.return_value = False
|
||||
self._call_hook('config-changed')
|
||||
_joined.assert_called_with('identity/0')
|
||||
|
Loading…
Reference in New Issue
Block a user