diff --git a/lib/ansible/modules/network/f5/bigip_profile_client_ssl.py b/lib/ansible/modules/network/f5/bigip_profile_client_ssl.py index 356a913ffd..638e571c09 100644 --- a/lib/ansible/modules/network/f5/bigip_profile_client_ssl.py +++ b/lib/ansible/modules/network/f5/bigip_profile_client_ssl.py @@ -1,12 +1,13 @@ #!/usr/bin/python # -*- coding: utf-8 -*- # -# Copyright (c) 2017 F5 Networks Inc. +# Copyright: (c) 2017, F5 Networks Inc. # GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) from __future__ import absolute_import, division, print_function __metaclass__ = type + ANSIBLE_METADATA = {'metadata_version': '1.1', 'status': ['preview'], 'supported_by': 'certified'} @@ -97,12 +98,13 @@ options: - pkcs1-check-2 - netscape-ca-dn-bug - netscape-demo-cipher-change-bug + - "none" version_added: 2.7 secure_renegotiation: description: - Specifies the method of secure renegotiations for SSL connections. When creating a new profile, the setting is provided by the parent profile. - - When C(request) is set the ssystem request secure renegotation of SSL + - When C(request) is set the system request secure renegotation of SSL connections. - C(require) is a default setting and when set the system permits initial SSL handshakes from clients but terminates renegotiations from unpatched clients. @@ -120,6 +122,79 @@ options: - When creating a new profile, the setting is provided by the parent profile. type: bool version_added: 2.7 + server_name: + description: + - Specifies the fully qualified DNS hostname of the server used in Server Name Indication communications. + When creating a new profile, the setting is provided by the parent profile. + - The server name can also be a wildcard string containing the asterisk C(*) character. + version_added: 2.8 + sni_default: + description: + - Indicates that the system uses this profile as the default SSL profile when there is no match to the + server name, or when the client provides no SNI extension support. + - When creating a new profile, the setting is provided by the parent profile. + - There can be only one SSL profile with this setting enabled. + type: bool + version_added: 2.8 + sni_require: + description: + - Requires that the network peers also provide SNI support. This setting only takes effect when C(sni_default) is + set to C(true). When creating a new profile, the setting is provided by the parent profile. + type: bool + version_added: 2.8 + client_certificate: + description: + - Specifies the way the system handles client certificates. + - When C(ignore), specifies that the system ignores certificates from client + systems. + - When C(require), specifies that the system requires a client to present a + valid certificate. + - When C(request), specifies that the system requests a valid certificate from a + client but always authenticate the client. + choices: + - ignore + - require + - request + version_added: 2.8 + client_auth_frequency: + description: + - Specifies the frequency of client authentication for an SSL session. + - When C(once), specifies that the system authenticates the client once for an + SSL session. + - When C(always), specifies that the system authenticates the client once for an + SSL session and also upon reuse of that session. + choices: + - once + - always + version_added: 2.8 + retain_certificate: + description: + - When C(yes), client certificate is retained in SSL session. + type: bool + version_added: 2.8 + cert_auth_depth: + description: + - Specifies the maximum number of certificates to be traversed in a client + certificate chain. + version_added: 2.8 + trusted_cert_authority: + description: + - Specifies a client CA that the system trusts. + version_added: 2.8 + advertised_cert_authority: + description: + - Specifies that the CAs that the system advertises to clients is being trusted + by the profile. + version_added: 2.8 + client_auth_crl: + description: + - Specifies the name of a file containing a list of revoked client certificates. + version_added: 2.8 + allow_expired_crl: + description: + - Instructs the system to use the specified CRL file even if it has expired. + type: bool + version_added: 2.8 state: description: - When C(present), ensures that the profile exists. @@ -223,33 +298,29 @@ from ansible.module_utils.basic import env_fallback from ansible.module_utils.six import iteritems try: - from library.module_utils.network.f5.bigip import HAS_F5SDK - from library.module_utils.network.f5.bigip import F5Client + from library.module_utils.network.f5.bigip import F5RestClient from library.module_utils.network.f5.common import F5ModuleError from library.module_utils.network.f5.common import AnsibleF5Parameters from library.module_utils.network.f5.common import cleanup_tokens from library.module_utils.network.f5.common import fq_name from library.module_utils.network.f5.common import f5_argument_spec from library.module_utils.network.f5.common import flatten_boolean + from library.module_utils.network.f5.common import transform_name + from library.module_utils.network.f5.common import exit_json + from library.module_utils.network.f5.common import fail_json from library.module_utils.network.f5.common import is_empty_list - try: - from library.module_utils.network.f5.common import iControlUnexpectedHTTPError - except ImportError: - HAS_F5SDK = False except ImportError: - from ansible.module_utils.network.f5.bigip import HAS_F5SDK - from ansible.module_utils.network.f5.bigip import F5Client + from ansible.module_utils.network.f5.bigip import F5RestClient from ansible.module_utils.network.f5.common import F5ModuleError from ansible.module_utils.network.f5.common import AnsibleF5Parameters from ansible.module_utils.network.f5.common import cleanup_tokens from ansible.module_utils.network.f5.common import fq_name from ansible.module_utils.network.f5.common import f5_argument_spec from ansible.module_utils.network.f5.common import flatten_boolean + from ansible.module_utils.network.f5.common import transform_name + from ansible.module_utils.network.f5.common import exit_json + from ansible.module_utils.network.f5.common import fail_json from ansible.module_utils.network.f5.common import is_empty_list - try: - from ansible.module_utils.network.f5.common import iControlUnexpectedHTTPError - except ImportError: - HAS_F5SDK = False class Parameters(AnsibleF5Parameters): @@ -259,24 +330,86 @@ class Parameters(AnsibleF5Parameters): 'allowNonSsl': 'allow_non_ssl', 'secureRenegotiation': 'secure_renegotiation', 'tmOptions': 'options', + 'sniDefault': 'sni_default', + 'sniRequire': 'sni_require', + 'serverName': 'server_name', + 'peerCertMode': 'client_certificate', + 'authenticate': 'client_auth_frequency', + 'retainCertificate': 'retain_certificate', + 'authenticateDepth': 'cert_auth_depth', + 'caFile': 'trusted_cert_authority', + 'clientCertCa': 'advertised_cert_authority', + 'crlFile': 'client_auth_crl', + 'allowExpiredCrl': 'allow_expired_crl', } api_attributes = [ - 'ciphers', 'certKeyChain', - 'defaultsFrom', 'tmOptions', - 'secureRenegotiation', 'allowNonSsl', + 'ciphers', + 'certKeyChain', + 'defaultsFrom', + 'tmOptions', + 'secureRenegotiation', + 'allowNonSsl', + 'sniDefault', + 'sniRequire', + 'serverName', + 'peerCertMode', + 'authenticate', + 'retainCertificate', + 'authenticateDepth', + 'caFile', + 'clientCertCa', + 'crlFile', + 'allowExpiredCrl', ] returnables = [ - 'ciphers', 'allow_non_ssl', 'options', + 'ciphers', + 'allow_non_ssl', + 'options', 'secure_renegotiation', + 'cert_key_chain', + 'parent', + 'sni_default', + 'sni_require', + 'server_name', + 'client_certificate', + 'client_auth_frequency', + 'retain_certificate', + 'cert_auth_depth', + 'trusted_cert_authority', + 'advertised_cert_authority', + 'client_auth_crl', + 'allow_expired_crl', ] updatables = [ - 'ciphers', 'cert_key_chain', 'allow_non_ssl', - 'options', 'secure_renegotiation', + 'ciphers', + 'cert_key_chain', + 'allow_non_ssl', + 'options', + 'secure_renegotiation', + 'sni_default', + 'sni_require', + 'server_name', + 'client_certificate', + 'client_auth_frequency', + 'retain_certificate', + 'cert_auth_depth', + 'trusted_cert_authority', + 'advertised_cert_authority', + 'client_auth_crl', + 'allow_expired_crl', ] + @property + def retain_certificate(self): + return flatten_boolean(self._values['retain_certificate']) + + @property + def allow_expired_crl(self): + return flatten_boolean(self._values['allow_expired_crl']) + class ModuleParameters(Parameters): def _key_filename(self, name): @@ -302,6 +435,8 @@ class ModuleParameters(Parameters): def parent(self): if self._values['parent'] is None: return None + if self._values['parent'] == 'clientssl': + return '/Common/clientssl' result = fq_name(self.partition, self._values['parent']) return result @@ -347,45 +482,54 @@ class ModuleParameters(Parameters): @property def options(self): - choices = [ - 'netscape-reuse-cipher-change-bug', - 'microsoft-big-sslv3-buffer', - 'msie-sslv2-rsa-padding', - 'ssleay-080-client-dh-bug', - 'tls-d5-bug', - 'tls-block-padding-bug', - 'dont-insert-empty-fragments', - 'no-ssl', - 'no-dtls', - 'no-session-resumption-on-renegotiation', - 'no-tlsv1.1', - 'no-tlsv1.2', - 'single-dh-use', - 'ephemeral-rsa', - 'cipher-server-preference', - 'tls-rollback-bug', - 'no-sslv2', - 'no-sslv3', - 'no-tls', - 'no-tlsv1', - 'pkcs1-check-1', - 'pkcs1-check-2', - 'netscape-ca-dn-bug', - 'netscape-demo-cipher-change-bug' - ] options = self._values['options'] - if options is None: return None - if is_empty_list(options): return [] + return options - if set(options).issubset(set(choices)): - return options + @property + def sni_require(self): + require = flatten_boolean(self._values['sni_require']) + default = self.sni_default + if require is None: + return None + if default in [None, False]: + if require == 'yes': + raise F5ModuleError( + "Cannot set 'sni_require' to {0} if 'sni_default' is set as {1}".format(require, default)) + if require == 'yes': + return True else: - offenders = set(options).difference(set(choices)) - raise F5ModuleError('Invalid options specified: {0}'.format(offenders)) + return False + + @property + def trusted_cert_authority(self): + if self._values['trusted_cert_authority'] is None: + return None + if self._values['trusted_cert_authority'] in ['', 'none']: + return '' + result = fq_name(self.partition, self._values['trusted_cert_authority']) + return result + + @property + def advertised_cert_authority(self): + if self._values['advertised_cert_authority'] is None: + return None + if self._values['advertised_cert_authority'] in ['', 'none']: + return '' + result = fq_name(self.partition, self._values['advertised_cert_authority']) + return result + + @property + def client_auth_crl(self): + if self._values['client_auth_crl'] is None: + return None + if self._values['client_auth_crl'] in ['', 'none']: + return '' + result = fq_name(self.partition, self._values['client_auth_crl']) + return result class ApiParameters(Parameters): @@ -407,6 +551,44 @@ class ApiParameters(Parameters): result = sorted(result, key=lambda y: y['name']) return result + @property + def sni_default(self): + result = self._values['sni_default'] + if result is None: + return None + if result == 'true': + return True + else: + return False + + @property + def sni_require(self): + result = self._values['sni_require'] + if result is None: + return None + if result == 'true': + return True + else: + return False + + @property + def trusted_cert_authority(self): + if self._values['trusted_cert_authority'] in [None, 'none']: + return None + return self._values['trusted_cert_authority'] + + @property + def advertised_cert_authority(self): + if self._values['advertised_cert_authority'] in [None, 'none']: + return None + return self._values['advertised_cert_authority'] + + @property + def client_auth_crl(self): + if self._values['client_auth_crl'] in [None, 'none']: + return None + return self._values['client_auth_crl'] + class Changes(Parameters): def to_return(self): @@ -421,7 +603,21 @@ class Changes(Parameters): class UsableChanges(Changes): - pass + @property + def retain_certificate(self): + if self._values['retain_certificate'] is None: + return None + elif self._values['retain_certificate'] == 'yes': + return 'true' + return 'false' + + @property + def allow_expired_crl(self): + if self._values['allow_expired_crl'] is None: + return None + elif self._values['allow_expired_crl'] == 'yes': + return 'enabled' + return 'disabled' class ReportableChanges(Changes): @@ -433,6 +629,14 @@ class ReportableChanges(Changes): return 'yes' return 'no' + @property + def retain_certificate(self): + return flatten_boolean(self._values['retain_certificate']) + + @property + def allow_expired_crl(self): + return flatten_boolean(self._values['allow_expired_crl']) + class Difference(object): def __init__(self, want, have=None): @@ -503,6 +707,47 @@ class Difference(object): if set(self.want.options) != set(self.have.options): return self.want.options + @property + def sni_require(self): + if self.want.sni_require is None: + return None + if self.want.sni_require is False: + if self.have.sni_default is True and self.want.sni_default is None: + raise F5ModuleError( + "Cannot set 'sni_require' to {0} if 'sni_default' is {1}".format( + self.want.sni_require, self.have.sni_default) + ) + if self.want.sni_require == self.have.sni_require: + return None + return self.want.sni_require + + @property + def trusted_cert_authority(self): + if self.want.trusted_cert_authority is None: + return None + if self.want.trusted_cert_authority == '' and self.have.trusted_cert_authority is None: + return None + if self.want.trusted_cert_authority != self.have.trusted_cert_authority: + return self.want.trusted_cert_authority + + @property + def advertised_cert_authority(self): + if self.want.advertised_cert_authority is None: + return None + if self.want.advertised_cert_authority == '' and self.have.advertised_cert_authority is None: + return None + if self.want.advertised_cert_authority != self.have.advertised_cert_authority: + return self.want.advertised_cert_authority + + @property + def client_auth_crl(self): + if self.want.client_auth_crl is None: + return None + if self.want.client_auth_crl == '' and self.have.client_auth_crl is None: + return None + if self.want.client_auth_crl != self.have.client_auth_crl: + return self.want.client_auth_crl + class ModuleManager(object): def __init__(self, *args, **kwargs): @@ -538,18 +783,21 @@ class ModuleManager(object): return True return False + def should_update(self): + result = self._update_changed_options() + if result: + return True + return False + def exec_module(self): changed = False result = dict() state = self.want.state - try: - if state == "present": - changed = self.present() - elif state == "absent": - changed = self.absent() - except iControlUnexpectedHTTPError as e: - raise F5ModuleError(str(e)) + if state == "present": + changed = self.present() + elif state == "absent": + changed = self.absent() reportable = ReportableChanges(params=self.changes.to_return()) changes = reportable.to_return() @@ -561,7 +809,7 @@ class ModuleManager(object): def _announce_deprecations(self, result): warnings = result.pop('__warnings', []) for warning in warnings: - self.module.deprecate( + self.client.module.deprecate( msg=warning['msg'], version=warning['version'] ) @@ -572,19 +820,21 @@ class ModuleManager(object): else: return self.create() - def create(self): - self._set_changed_options() - if self.module.check_mode: - return True - self.create_on_device() + def exists(self): + uri = "https://{0}:{1}/mgmt/tm/ltm/profile/client-ssl/{2}".format( + self.client.provider['server'], + self.client.provider['server_port'], + transform_name(self.want.partition, self.want.name) + ) + resp = self.client.api.get(uri) + try: + response = resp.json() + except ValueError: + return False + if resp.status == 404 or 'code' in response and response['code'] == 404: + return False return True - def should_update(self): - result = self._update_changed_options() - if result: - return True - return False - def update(self): self.have = self.read_current_from_device() if not self.should_update(): @@ -594,57 +844,94 @@ class ModuleManager(object): self.update_on_device() return True - def absent(self): - if self.exists(): - return self.remove() - return False - def remove(self): if self.module.check_mode: return True self.remove_from_device() if self.exists(): - raise F5ModuleError("Failed to delete the profile.") + raise F5ModuleError("Failed to delete the resource.") return True - def read_current_from_device(self): - resource = self.client.api.tm.ltm.profile.client_ssls.client_ssl.load( - name=self.want.name, - partition=self.want.partition - ) - result = resource.attrs - return ApiParameters(params=result) + def create(self): + self._set_changed_options() + if self.module.check_mode: + return True + self.create_on_device() + return True - def exists(self): - result = self.client.api.tm.ltm.profile.client_ssls.client_ssl.exists( - name=self.want.name, - partition=self.want.partition + def create_on_device(self): + params = self.changes.api_params() + params['name'] = self.want.name + params['partition'] = self.want.partition + uri = "https://{0}:{1}/mgmt/tm/ltm/profile/client-ssl/".format( + self.client.provider['server'], + self.client.provider['server_port'] ) - return result + resp = self.client.api.post(uri, json=params) + try: + response = resp.json() + except ValueError as ex: + raise F5ModuleError(str(ex)) + + if 'code' in response and response['code'] in [400, 403]: + if 'message' in response: + raise F5ModuleError(response['message']) + else: + raise F5ModuleError(resp.content) def update_on_device(self): params = self.changes.api_params() - result = self.client.api.tm.ltm.profile.client_ssls.client_ssl.load( - name=self.want.name, - partition=self.want.partition + uri = "https://{0}:{1}/mgmt/tm/ltm/profile/client-ssl/{2}".format( + self.client.provider['server'], + self.client.provider['server_port'], + transform_name(self.want.partition, self.want.name) ) - result.modify(**params) + resp = self.client.api.patch(uri, json=params) + try: + response = resp.json() + except ValueError as ex: + raise F5ModuleError(str(ex)) - def create_on_device(self): - params = self.want.api_params() - self.client.api.tm.ltm.profile.client_ssls.client_ssl.create( - name=self.want.name, - partition=self.want.partition, - **params - ) + if 'code' in response and response['code'] == 400: + if 'message' in response: + raise F5ModuleError(response['message']) + else: + raise F5ModuleError(resp.content) + + def absent(self): + if self.exists(): + return self.remove() + return False def remove_from_device(self): - result = self.client.api.tm.ltm.profile.client_ssls.client_ssl.load( - name=self.want.name, - partition=self.want.partition + uri = "https://{0}:{1}/mgmt/tm/ltm/profile/client-ssl/{2}".format( + self.client.provider['server'], + self.client.provider['server_port'], + transform_name(self.want.partition, self.want.name) ) - if result: - result.delete() + response = self.client.api.delete(uri) + if response.status == 200: + return True + raise F5ModuleError(response.content) + + def read_current_from_device(self): + uri = "https://{0}:{1}/mgmt/tm/ltm/profile/client-ssl/{2}".format( + self.client.provider['server'], + self.client.provider['server_port'], + transform_name(self.want.partition, self.want.name) + ) + resp = self.client.api.get(uri) + try: + response = resp.json() + except ValueError as ex: + raise F5ModuleError(str(ex)) + + if 'code' in response and response['code'] == 400: + if 'message' in response: + raise F5ModuleError(response['message']) + else: + raise F5ModuleError(resp.content) + return ApiParameters(params=response) class ArgumentSpec(object): @@ -685,6 +972,7 @@ class ArgumentSpec(object): 'pkcs1-check-2', 'netscape-ca-dn-bug', 'netscape-demo-cipher-change-bug', + 'none', ] ), cert_key_chain=dict( @@ -700,6 +988,21 @@ class ArgumentSpec(object): default='present', choices=['present', 'absent'] ), + sni_default=dict(type='bool'), + sni_require=dict(type='bool'), + server_name=dict(), + client_certificate=dict( + choices=['require', 'ignore', 'request'] + ), + client_auth_frequency=dict( + choices=['once', 'always'] + ), + cert_auth_depth=dict(type='int'), + retain_certificate=dict(type='bool'), + trusted_cert_authority=dict(), + advertised_cert_authority=dict(), + client_auth_crl=dict(), + allow_expired_crl=dict(type='bool'), partition=dict( default='Common', fallback=(env_fallback, ['F5_PARTITION']) @@ -712,23 +1015,22 @@ class ArgumentSpec(object): def main(): spec = ArgumentSpec() + module = AnsibleModule( argument_spec=spec.argument_spec, - supports_check_mode=spec.supports_check_mode + supports_check_mode=spec.supports_check_mode, ) - if not HAS_F5SDK: - module.fail_json(msg="The python f5-sdk module is required") - client = F5Client(**module.params) + client = F5RestClient(**module.params) try: mm = ModuleManager(module=module, client=client) results = mm.exec_module() cleanup_tokens(client) - module.exit_json(**results) + exit_json(module, results, client) except F5ModuleError as ex: cleanup_tokens(client) - module.fail_json(msg=str(ex)) + fail_json(module, ex, client) if __name__ == '__main__': diff --git a/test/units/modules/network/f5/test_bigip_profile_client_ssl.py b/test/units/modules/network/f5/test_bigip_profile_client_ssl.py index c4ccd40260..d99306a9cb 100644 --- a/test/units/modules/network/f5/test_bigip_profile_client_ssl.py +++ b/test/units/modules/network/f5/test_bigip_profile_client_ssl.py @@ -8,16 +8,12 @@ __metaclass__ = type import os import json -import pytest import sys from nose.plugins.skip import SkipTest if sys.version_info < (2, 7): raise SkipTest("F5 Ansible modules require Python >= 2.7") -from units.compat import unittest -from units.compat.mock import Mock -from units.compat.mock import patch from ansible.module_utils.basic import AnsibleModule try: @@ -25,17 +21,25 @@ try: from library.modules.bigip_profile_client_ssl import ApiParameters from library.modules.bigip_profile_client_ssl import ModuleManager from library.modules.bigip_profile_client_ssl import ArgumentSpec - from library.module_utils.network.f5.common import F5ModuleError - from library.module_utils.network.f5.common import iControlUnexpectedHTTPError - from test.unit.modules.utils import set_module_args + + # In Ansible 2.8, Ansible changed import paths. + from test.units.compat import unittest + from test.units.compat.mock import Mock + from test.units.compat.mock import patch + + from test.units.modules.utils import set_module_args except ImportError: try: from ansible.modules.network.f5.bigip_profile_client_ssl import ModuleParameters from ansible.modules.network.f5.bigip_profile_client_ssl import ApiParameters from ansible.modules.network.f5.bigip_profile_client_ssl import ModuleManager from ansible.modules.network.f5.bigip_profile_client_ssl import ArgumentSpec - from ansible.module_utils.network.f5.common import F5ModuleError - from ansible.module_utils.network.f5.common import iControlUnexpectedHTTPError + + # Ansible 2.8 imports + from units.compat import unittest + from units.compat.mock import Mock + from units.compat.mock import patch + from units.modules.utils import set_module_args except ImportError: raise SkipTest("F5 Ansible modules require the f5-sdk Python library")