diff --git a/charmhelpers/contrib/openstack/audits/openstack_security_guide.py b/charmhelpers/contrib/openstack/audits/openstack_security_guide.py index ba5e248..e5b7ac1 100644 --- a/charmhelpers/contrib/openstack/audits/openstack_security_guide.py +++ b/charmhelpers/contrib/openstack/audits/openstack_security_guide.py @@ -30,14 +30,20 @@ from charmhelpers.core.hookenv import ( cached, ) +""" +The Security Guide suggests a specific list of files inside the +config directory for the service having 640 specifically, but +by ensuring the containing directory is 750, only the owner can +write, and only the group can read files within the directory. +By restricting access to the containing directory, we can more +effectively ensure that there is no accidental leakage if a new +file is added to the service without being added to the security +guide, and to this check. +""" FILE_ASSERTIONS = { 'barbican': { - # From security guide - '/etc/barbican/barbican.conf': {'group': 'barbican', 'mode': '640'}, - '/etc/barbican/barbican-api-paste.ini': - {'group': 'barbican', 'mode': '640'}, - '/etc/barbican/policy.json': {'group': 'barbican', 'mode': '640'}, + '/etc/barbican': {'group': 'barbican', 'mode': '750'}, }, 'ceph-mon': { '/var/lib/charm/ceph-mon/ceph.conf': @@ -60,82 +66,29 @@ FILE_ASSERTIONS = { {'owner': 'ceph', 'group': 'ceph', 'mode': '755'}, }, 'cinder': { - # From security guide - '/etc/cinder/cinder.conf': {'group': 'cinder', 'mode': '640'}, - '/etc/cinder/api-paste.conf': {'group': 'cinder', 'mode': '640'}, - '/etc/cinder/rootwrap.conf': {'group': 'cinder', 'mode': '640'}, + '/etc/cinder': {'group': 'cinder', 'mode': '750'}, }, 'glance': { - # From security guide - '/etc/glance/glance-api-paste.ini': {'group': 'glance', 'mode': '640'}, - '/etc/glance/glance-api.conf': {'group': 'glance', 'mode': '640'}, - '/etc/glance/glance-cache.conf': {'group': 'glance', 'mode': '640'}, - '/etc/glance/glance-manage.conf': {'group': 'glance', 'mode': '640'}, - '/etc/glance/glance-registry-paste.ini': - {'group': 'glance', 'mode': '640'}, - '/etc/glance/glance-registry.conf': {'group': 'glance', 'mode': '640'}, - '/etc/glance/glance-scrubber.conf': {'group': 'glance', 'mode': '640'}, - '/etc/glance/glance-swift-store.conf': - {'group': 'glance', 'mode': '640'}, - '/etc/glance/policy.json': {'group': 'glance', 'mode': '640'}, - '/etc/glance/schema-image.json': {'group': 'glance', 'mode': '640'}, - '/etc/glance/schema.json': {'group': 'glance', 'mode': '640'}, + '/etc/glance': {'group': 'glance', 'mode': '750'}, }, 'keystone': { - # From security guide - '/etc/keystone/keystone.conf': {'group': 'keystone', 'mode': '640'}, - '/etc/keystone/keystone-paste.ini': - {'group': 'keystone', 'mode': '640'}, - '/etc/keystone/policy.json': {'group': 'keystone', 'mode': '640'}, - '/etc/keystone/logging.conf': {'group': 'keystone', 'mode': '640'}, - '/etc/keystone/ssl/certs/signing_cert.pem': - {'group': 'keystone', 'mode': '640'}, - '/etc/keystone/ssl/private/signing_key.pem': - {'group': 'keystone', 'mode': '640'}, - '/etc/keystone/ssl/certs/ca.pem': {'group': 'keystone', 'mode': '640'}, + '/etc/keystone': + {'owner': 'keystone', 'group': 'keystone', 'mode': '750'}, }, 'manilla': { - # From security guide - '/etc/manila/manila.conf': {'group': 'manilla', 'mode': '640'}, - '/etc/manila/api-paste.ini': {'group': 'manilla', 'mode': '640'}, - '/etc/manila/policy.json': {'group': 'manilla', 'mode': '640'}, - '/etc/manila/rootwrap.conf': {'group': 'manilla', 'mode': '640'}, + '/etc/manila': {'group': 'manilla', 'mode': '750'}, }, 'neutron-gateway': { - '/etc/neutron/neutron.conf': {'group': 'neutron', 'mode': '640'}, - '/etc/neutron/rootwrap.conf': {'mode': '640'}, - '/etc/neutron/rootwrap.d': {'mode': '755'}, - '/etc/neutron/*': {'group': 'neutron', 'mode': '644'}, + '/etc/neutron': {'group': 'neutron', 'mode': '750'}, }, 'neutron-api': { - # From security guide - '/etc/neutron/neutron.conf': {'group': 'neutron', 'mode': '640'}, - '/etc/nova/api-paste.ini': {'group': 'neutron', 'mode': '640'}, - '/etc/neutron/rootwrap.conf': {'group': 'neutron', 'mode': '640'}, - # Additional validations - '/etc/neutron/rootwrap.d': {'mode': '755'}, - '/etc/neutron/neutron_lbaas.conf': {'mode': '644'}, - '/etc/neutron/neutron_vpnaas.conf': {'mode': '644'}, - '/etc/neutron/*': {'group': 'neutron', 'mode': '644'}, + '/etc/neutron/': {'group': 'neutron', 'mode': '750'}, }, 'nova-cloud-controller': { - # From security guide - '/etc/nova/api-paste.ini': {'group': 'nova', 'mode': '640'}, - '/etc/nova/nova.conf': {'group': 'nova', 'mode': '750'}, - '/etc/nova/*': {'group': 'nova', 'mode': '640'}, - # Additional validations - '/etc/nova/logging.conf': {'group': 'nova', 'mode': '640'}, + '/etc/nova': {'group': 'nova', 'mode': '750'}, }, 'nova-compute': { - # From security guide - '/etc/nova/nova.conf': {'group': 'nova', 'mode': '640'}, - '/etc/nova/api-paste.ini': {'group': 'nova', 'mode': '640'}, - '/etc/nova/rootwrap.conf': {'group': 'nova', 'mode': '640'}, - # Additional Validations - '/etc/nova/nova-compute.conf': {'group': 'nova', 'mode': '640'}, - '/etc/nova/logging.conf': {'group': 'nova', 'mode': '640'}, - '/etc/nova/nm.conf': {'mode': '644'}, - '/etc/nova/*': {'group': 'nova', 'mode': '640'}, + '/etc/nova/': {'group': 'nova', 'mode': '750'}, }, 'openstack-dashboard': { # From security guide @@ -178,7 +131,7 @@ def _config_ini(path): return dict(conf) -def _validate_file_ownership(owner, group, file_name): +def _validate_file_ownership(owner, group, file_name, optional=False): """ Validate that a specified file is owned by `owner:group`. @@ -188,12 +141,16 @@ def _validate_file_ownership(owner, group, file_name): :type group: str :param file_name: Path to the file to verify :type file_name: str + :param optional: Is this file optional, + ie: Should this test fail when it's missing + :type optional: bool """ try: ownership = _stat(file_name) except subprocess.CalledProcessError as e: print("Error reading file: {}".format(e)) - assert False, "Specified file does not exist: {}".format(file_name) + if not optional: + assert False, "Specified file does not exist: {}".format(file_name) assert owner == ownership.owner, \ "{} has an incorrect owner: {} should be {}".format( file_name, ownership.owner, owner) @@ -203,7 +160,7 @@ def _validate_file_ownership(owner, group, file_name): print("Validate ownership of {}: PASS".format(file_name)) -def _validate_file_mode(mode, file_name): +def _validate_file_mode(mode, file_name, optional=False): """ Validate that a specified file has the specified permissions. @@ -211,12 +168,16 @@ def _validate_file_mode(mode, file_name): :type owner: str :param file_name: Path to the file to verify :type file_name: str + :param optional: Is this file optional, + ie: Should this test fail when it's missing + :type optional: bool """ try: ownership = _stat(file_name) except subprocess.CalledProcessError as e: print("Error reading file: {}".format(e)) - assert False, "Specified file does not exist: {}".format(file_name) + if not optional: + assert False, "Specified file does not exist: {}".format(file_name) assert mode == ownership.mode, \ "{} has an incorrect mode: {} should be {}".format( file_name, ownership.mode, mode) @@ -243,14 +204,15 @@ def validate_file_ownership(config): "Invalid ownership configuration: {}".format(key)) owner = options.get('owner', config.get('owner', 'root')) group = options.get('group', config.get('group', 'root')) + optional = options.get('optional', config.get('optional', 'False')) if '*' in file_name: for file in glob.glob(file_name): if file not in files.keys(): if os.path.isfile(file): - _validate_file_ownership(owner, group, file) + _validate_file_ownership(owner, group, file, optional) else: if os.path.isfile(file_name): - _validate_file_ownership(owner, group, file_name) + _validate_file_ownership(owner, group, file_name, optional) @audit(is_audit_type(AuditType.OpenStackSecurityGuide), @@ -264,14 +226,15 @@ def validate_file_permissions(config): raise RuntimeError( "Invalid ownership configuration: {}".format(key)) mode = options.get('mode', config.get('permissions', '600')) + optional = options.get('optional', config.get('optional', 'False')) if '*' in file_name: for file in glob.glob(file_name): if file not in files.keys(): if os.path.isfile(file): - _validate_file_mode(mode, file) + _validate_file_mode(mode, file, optional) else: if os.path.isfile(file_name): - _validate_file_mode(mode, file_name) + _validate_file_mode(mode, file_name, optional) @audit(is_audit_type(AuditType.OpenStackSecurityGuide)) diff --git a/charmhelpers/contrib/openstack/cert_utils.py b/charmhelpers/contrib/openstack/cert_utils.py index 3a3c6de..47b8603 100644 --- a/charmhelpers/contrib/openstack/cert_utils.py +++ b/charmhelpers/contrib/openstack/cert_utils.py @@ -180,13 +180,17 @@ def create_ip_cert_links(ssl_dir, custom_hostname_link=None): os.symlink(hostname_key, custom_key) -def install_certs(ssl_dir, certs, chain=None): +def install_certs(ssl_dir, certs, chain=None, user='root', group='root'): """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 + :param user: (Optional) Owner of certificate files. Defaults to 'root' + :type user: str + :param group: (Optional) Group of certificate files. Defaults to 'root' + :type group: str """ for cn, bundle in certs.items(): cert_filename = 'cert_{}'.format(cn) @@ -197,21 +201,25 @@ def install_certs(ssl_dir, certs, chain=None): # trust certs signed by an intermediate in the chain cert_data = cert_data + os.linesep + chain write_file( - path=os.path.join(ssl_dir, cert_filename), + path=os.path.join(ssl_dir, cert_filename), owner=user, group=group, content=cert_data, perms=0o640) write_file( - path=os.path.join(ssl_dir, key_filename), + path=os.path.join(ssl_dir, key_filename), owner=user, group=group, content=bundle['key'], perms=0o640) def process_certificates(service_name, relation_id, unit, - custom_hostname_link=None): + custom_hostname_link=None, user='root', group='root'): """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 + :param user: (Optional) Owner of certificate files. Defaults to 'root' + :type user: str + :param group: (Optional) Group of certificate files. Defaults to 'root' + :type group: str """ data = relation_get(rid=relation_id, unit=unit) ssl_dir = os.path.join('/etc/apache2/ssl/', service_name) @@ -223,7 +231,7 @@ def process_certificates(service_name, relation_id, unit, if certs: certs = json.loads(certs) install_ca_cert(ca.encode()) - install_certs(ssl_dir, certs, chain) + install_certs(ssl_dir, certs, chain, user=user, group=group) 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 fc634cc..d513371 100644 --- a/charmhelpers/contrib/openstack/context.py +++ b/charmhelpers/contrib/openstack/context.py @@ -792,6 +792,7 @@ class ApacheSSLContext(OSContextGenerator): # and service namespace accordingly. external_ports = [] service_namespace = None + user = group = 'root' def enable_modules(self): cmd = ['a2enmod', 'ssl', 'proxy', 'proxy_http', 'headers'] @@ -810,9 +811,11 @@ class ApacheSSLContext(OSContextGenerator): key_filename = 'key' write_file(path=os.path.join(ssl_dir, cert_filename), - content=b64decode(cert), perms=0o640) + content=b64decode(cert), owner=self.user, + group=self.group, perms=0o640) write_file(path=os.path.join(ssl_dir, key_filename), - content=b64decode(key), perms=0o640) + content=b64decode(key), owner=self.user, + group=self.group, perms=0o640) def configure_ca(self): ca_cert = get_ca_cert() @@ -1932,3 +1935,30 @@ class VersionsContext(OSContextGenerator): return { 'openstack_release': ostack, 'operating_system_release': osystem} + + +class LogrotateContext(OSContextGenerator): + """Common context generator for logrotate.""" + + def __init__(self, location, interval, count): + """ + :param location: Absolute path for the logrotate config file + :type location: str + :param interval: The interval for the rotations. Valid values are + 'daily', 'weekly', 'monthly', 'yearly' + :type interval: str + :param count: The logrotate count option configures the 'count' times + the log files are being rotated before being + :type count: int + """ + self.location = location + self.interval = interval + self.count = 'rotate {}'.format(count) + + def __call__(self): + ctxt = { + 'logrotate_logs_location': self.location, + 'logrotate_interval': self.interval, + 'logrotate_count': self.count, + } + return ctxt diff --git a/charmhelpers/contrib/openstack/ip.py b/charmhelpers/contrib/openstack/ip.py index df83b91..723aebc 100644 --- a/charmhelpers/contrib/openstack/ip.py +++ b/charmhelpers/contrib/openstack/ip.py @@ -159,7 +159,7 @@ def resolve_address(endpoint_type=PUBLIC, override=True): if is_address_in_network(bound_cidr, vip): resolved_address = vip break - except NotImplementedError: + except (NotImplementedError, NoNetworkBinding): # If no net-splits configured and no support for extra # bindings/network spaces so we expect a single vip resolved_address = vips[0] diff --git a/charmhelpers/contrib/openstack/templates/logrotate b/charmhelpers/contrib/openstack/templates/logrotate new file mode 100644 index 0000000..b2900d0 --- /dev/null +++ b/charmhelpers/contrib/openstack/templates/logrotate @@ -0,0 +1,9 @@ +/var/log/{{ logrotate_logs_location }}/*.log { + {{ logrotate_interval }} + {{ logrotate_count }} + compress + delaycompress + missingok + notifempty + copytruncate +} diff --git a/charmhelpers/contrib/storage/linux/utils.py b/charmhelpers/contrib/storage/linux/utils.py index 6f846b0..c57aaf3 100644 --- a/charmhelpers/contrib/storage/linux/utils.py +++ b/charmhelpers/contrib/storage/linux/utils.py @@ -17,12 +17,53 @@ import re from stat import S_ISBLK from subprocess import ( + CalledProcessError, check_call, check_output, call ) +def _luks_uuid(dev): + """ + Check to see if dev is a LUKS encrypted volume, returning the UUID + of volume if it is. + + :param: dev: path to block device to check. + :returns: str. UUID of LUKS device or None if not a LUKS device + """ + try: + cmd = ['cryptsetup', 'luksUUID', dev] + return check_output(cmd).decode('UTF-8').strip() + except CalledProcessError: + return None + + +def is_luks_device(dev): + """ + Determine if dev is a LUKS-formatted block device. + + :param: dev: A full path to a block device to check for LUKS header + presence + :returns: boolean: indicates whether a device is used based on LUKS header. + """ + return True if _luks_uuid(dev) else False + + +def is_mapped_luks_device(dev): + """ + Determine if dev is a mapped LUKS device + :param: dev: A full path to a block device to be checked + :returns: boolean: indicates whether a device is mapped + """ + _, dirs, _ = next(os.walk( + '/sys/class/block/{}/holders/' + .format(os.path.basename(os.path.realpath(dev)))) + ) + is_held = len(dirs) > 0 + return is_held and is_luks_device(dev) + + def is_block_device(path): ''' Confirm device at path is a valid block device node. diff --git a/charmhelpers/core/host.py b/charmhelpers/core/host.py index 47c1fc3..32754ff 100644 --- a/charmhelpers/core/host.py +++ b/charmhelpers/core/host.py @@ -47,6 +47,7 @@ if __platform__ == "ubuntu": cmp_pkgrevno, CompareHostReleases, get_distrib_codename, + arch ) # flake8: noqa -- ignore F401 for this import elif __platform__ == "centos": from charmhelpers.core.host_factory.centos import ( # NOQA:F401 diff --git a/charmhelpers/core/host_factory/ubuntu.py b/charmhelpers/core/host_factory/ubuntu.py index d7e920e..a3162fa 100644 --- a/charmhelpers/core/host_factory/ubuntu.py +++ b/charmhelpers/core/host_factory/ubuntu.py @@ -1,5 +1,6 @@ import subprocess +from charmhelpers.core.hookenv import cached from charmhelpers.core.strutils import BasicStringComparator @@ -97,3 +98,16 @@ def cmp_pkgrevno(package, revno, pkgcache=None): pkgcache = apt_cache() pkg = pkgcache[package] return apt_pkg.version_compare(pkg.current_ver.ver_str, revno) + + +@cached +def arch(): + """Return the package architecture as a string. + + :returns: the architecture + :rtype: str + :raises: subprocess.CalledProcessError if dpkg command fails + """ + return subprocess.check_output( + ['dpkg', '--print-architecture'] + ).rstrip().decode('UTF-8') diff --git a/charmhelpers/core/sysctl.py b/charmhelpers/core/sysctl.py index 1f188d8..f1f4a28 100644 --- a/charmhelpers/core/sysctl.py +++ b/charmhelpers/core/sysctl.py @@ -28,7 +28,7 @@ from charmhelpers.core.hookenv import ( __author__ = 'Jorge Niedbalski R. <jorge.niedbalski@canonical.com>' -def create(sysctl_dict, sysctl_file): +def create(sysctl_dict, sysctl_file, ignore=False): """Creates a sysctl.conf file from a YAML associative array :param sysctl_dict: a dict or YAML-formatted string of sysctl @@ -36,6 +36,8 @@ def create(sysctl_dict, sysctl_file): :type sysctl_dict: str :param sysctl_file: path to the sysctl file to be saved :type sysctl_file: str or unicode + :param ignore: If True, ignore "unknown variable" errors. + :type ignore: bool :returns: None """ if type(sysctl_dict) is not dict: @@ -52,7 +54,12 @@ def create(sysctl_dict, sysctl_file): for key, value in sysctl_dict_parsed.items(): fd.write("{}={}\n".format(key, value)) - log("Updating sysctl_file: %s values: %s" % (sysctl_file, sysctl_dict_parsed), + log("Updating sysctl_file: {} values: {}".format(sysctl_file, + sysctl_dict_parsed), level=DEBUG) - check_call(["sysctl", "-p", sysctl_file]) + call = ["sysctl", "-p", sysctl_file] + if ignore: + call.append("-e") + + check_call(call)