From 485c819677427a21f7dfb49920ca21583b2a4345 Mon Sep 17 00:00:00 2001 From: Markus Bergholz Date: Thu, 8 Apr 2021 10:39:17 +0200 Subject: [PATCH] add wafv2_ip_set module (#449) * add wafv2 ip set module * expand meta/runtime with wafv2_ip_set modules --- meta/runtime.yml | 2 + plugins/modules/wafv2_ip_set.py | 345 ++++++++++++++++++ plugins/modules/wafv2_ip_set_info.py | 150 ++++++++ .../integration/targets/wafv2_ip_set/aliases | 5 + .../targets/wafv2_ip_set/defaults/main.yml | 2 + .../targets/wafv2_ip_set/tasks/main.yml | 213 +++++++++++ 6 files changed, 717 insertions(+) create mode 100644 plugins/modules/wafv2_ip_set.py create mode 100644 plugins/modules/wafv2_ip_set_info.py create mode 100644 tests/integration/targets/wafv2_ip_set/aliases create mode 100644 tests/integration/targets/wafv2_ip_set/defaults/main.yml create mode 100644 tests/integration/targets/wafv2_ip_set/tasks/main.yml diff --git a/meta/runtime.yml b/meta/runtime.yml index 5a4d9a71f3e..bce9a45f7d5 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -219,6 +219,8 @@ action_groups: - sqs_queue - sts_assume_role - sts_session_token + - wafv2_ip_set + - wafv2_ip_set_info plugin_routing: modules: diff --git a/plugins/modules/wafv2_ip_set.py b/plugins/modules/wafv2_ip_set.py new file mode 100644 index 00000000000..f183211bcf8 --- /dev/null +++ b/plugins/modules/wafv2_ip_set.py @@ -0,0 +1,345 @@ +#!/usr/bin/python +# Copyright: Ansible Project +# 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 + + +DOCUMENTATION = ''' +--- +module: wafv2_ip_set +version_added: 1.5.0 +author: + - "Markus Bergholz (@markuman)" +short_description: wafv2_ip_set +description: + - Create, modify and delete IP sets for WAFv2. +requirements: + - boto3 + - botocore +options: + state: + description: + - Whether the rule is present or absent. + choices: ["present", "absent"] + required: true + type: str + name: + description: + - The name of the IP set. + required: true + type: str + description: + description: + - Description of the IP set. + required: false + type: str + scope: + description: + - Specifies whether this is for an AWS CloudFront distribution or for a regional application, + such as API Gateway or Application LoadBalancer. + choices: ["CLOUDFRONT","REGIONAL"] + required: true + type: str + ip_address_version: + description: + - Specifies whether this is an IPv4 or an IPv6 IP set. + - Required when I(state=present). + choices: ["IPV4","IPV6"] + type: str + addresses: + description: + - Contains an array of strings that specify one or more IP addresses or blocks of IP addresses in + Classless Inter-Domain Routing (CIDR) notation. + - Required when I(state=present). + - When I(state=absent) and I(addresses) is defined, only the given IP addresses will be removed + from the IP set. The entire IP set itself will stay present. + type: list + elements: str + tags: + description: + - Key value pairs to associate with the resource. + - Currently tags are not visible. Nor in the web ui, nor via cli and nor in boto3. + required: false + type: dict + purge_addresses: + description: + - When set to C(no), keep the existing addresses in place. Will modify and add, but will not delete. + default: yes + type: bool + +extends_documentation_fragment: +- amazon.aws.aws +- amazon.aws.ec2 + +''' + +EXAMPLES = ''' +- name: test ip set + wafv2_ip_set: + name: test02 + state: present + description: hallo eins + scope: REGIONAL + ip_address_version: IPV4 + addresses: + - 8.8.8.8/32 + - 8.8.4.4/32 + tags: + A: B + C: D +''' + +RETURN = """ +addresses: + description: Current addresses of the ip set + sample: + - 8.8.8.8/32 + - 8.8.4.4/32 + returned: Always, as long as the ip set exists + type: list +arn: + description: IP set arn + sample: "arn:aws:wafv2:eu-central-1:11111111:regional/ipset/test02/4b007330-2934-4dc5-af24-82dcb3aeb127" + type: str + returned: Always, as long as the ip set exists +description: + description: Description of the ip set + sample: Some IP set description + returned: Always, as long as the ip set exists + type: str +ip_address_version: + description: IP version of the ip set + sample: IPV4 + type: str + returned: Always, as long as the ip set exists +name: + description: IP set name + sample: test02 + returned: Always, as long as the ip set exists + type: str +""" +from ansible_collections.amazon.aws.plugins.module_utils.core import AnsibleAWSModule, is_boto3_error_code, get_boto3_client_method_parameters +from ansible_collections.amazon.aws.plugins.module_utils.ec2 import camel_dict_to_snake_dict, ansible_dict_to_boto3_tag_list + +try: + from botocore.exceptions import ClientError, BotoCoreError +except ImportError: + pass # caught by AnsibleAWSModule + + +class IpSet: + def __init__(self, wafv2, name, scope, fail_json_aws): + self.wafv2 = wafv2 + self.name = name + self.scope = scope + self.fail_json_aws = fail_json_aws + self.existing_set, self.id, self.locktoken = self.get_set() + + def description(self): + return self.existing_set.get('Description') + + def get(self): + if self.existing_set: + return camel_dict_to_snake_dict(self.existing_set) + return None + + def remove(self): + try: + response = self.wafv2.delete_ip_set( + Name=self.name, + Scope=self.scope, + Id=self.id, + LockToken=self.locktoken + ) + except (BotoCoreError, ClientError) as e: + self.fail_json_aws(e, msg="Failed to remove wafv2 ip set.") + return {} + + def create(self, description, ip_address_version, addresses, tags): + req_obj = { + 'Name': self.name, + 'Scope': self.scope, + 'IPAddressVersion': ip_address_version, + 'Addresses': addresses, + } + + if description: + req_obj['Description'] = description + + if tags: + req_obj['Tags'] = ansible_dict_to_boto3_tag_list(tags) + + try: + response = self.wafv2.create_ip_set(**req_obj) + except (BotoCoreError, ClientError) as e: + self.fail_json_aws(e, msg="Failed to create wafv2 ip set.") + + self.existing_set, self.id, self.locktoken = self.get_set() + return camel_dict_to_snake_dict(self.existing_set) + + def update(self, description, addresses): + req_obj = { + 'Name': self.name, + 'Scope': self.scope, + 'Id': self.id, + 'Addresses': addresses, + 'LockToken': self.locktoken + } + + if description: + req_obj['Description'] = description + + try: + response = self.wafv2.update_ip_set(**req_obj) + except (BotoCoreError, ClientError) as e: + self.fail_json_aws(e, msg="Failed to update wafv2 ip set.") + + self.existing_set, self.id, self.locktoken = self.get_set() + return camel_dict_to_snake_dict(self.existing_set) + + def get_set(self): + response = self.list() + existing_set = None + id = None + locktoken = None + for item in response.get('IPSets'): + if item.get('Name') == self.name: + id = item.get('Id') + locktoken = item.get('LockToken') + arn = item.get('ARN') + if id: + try: + existing_set = self.wafv2.get_ip_set( + Name=self.name, + Scope=self.scope, + Id=id + ).get('IPSet') + except (BotoCoreError, ClientError) as e: + self.fail_json_aws(e, msg="Failed to get wafv2 ip set.") + + return existing_set, id, locktoken + + def list(self, Nextmarker=None): + # there is currently no paginator for wafv2 + req_obj = { + 'Scope': self.scope, + 'Limit': 100 + } + if Nextmarker: + req_obj['NextMarker'] = Nextmarker + + try: + response = self.wafv2.list_ip_sets(**req_obj) + if response.get('NextMarker'): + response['IPSets'] += self.list(Nextmarker=response.get('NextMarker')).get('IPSets') + except (BotoCoreError, ClientError) as e: + self.fail_json_aws(e, msg="Failed to list wafv2 ip set.") + + return response + + +def compare(existing_set, addresses, purge_addresses, state): + diff = False + new_rules = [] + existing_rules = existing_set.get('addresses') + if state == 'present': + if purge_addresses: + new_rules = addresses + if sorted(addresses) != sorted(existing_set.get('addresses')): + diff = True + + else: + for requested_rule in addresses: + if requested_rule not in existing_rules: + diff = True + new_rules.append(requested_rule) + + new_rules += existing_rules + else: + if purge_addresses and addresses: + for requested_rule in addresses: + if requested_rule in existing_rules: + diff = True + existing_rules.pop(existing_rules.index(requested_rule)) + new_rules = existing_rules + + return diff, new_rules + + +def main(): + + arg_spec = dict( + state=dict(type='str', required=True, choices=['present', 'absent']), + name=dict(type='str', required=True), + scope=dict(type='str', required=True, choices=['CLOUDFRONT', 'REGIONAL']), + description=dict(type='str'), + ip_address_version=dict(type='str', choices=['IPV4', 'IPV6']), + addresses=dict(type='list', elements='str'), + tags=dict(type='dict'), + purge_addresses=dict(type='bool', default=True) + ) + + module = AnsibleAWSModule( + argument_spec=arg_spec, + supports_check_mode=True, + required_if=[['state', 'present', ['ip_address_version', 'addresses']]] + ) + + state = module.params.get("state") + name = module.params.get("name") + scope = module.params.get("scope") + description = module.params.get("description") + ip_address_version = module.params.get("ip_address_version") + addresses = module.params.get("addresses") + tags = module.params.get("tags") + purge_addresses = module.params.get("purge_addresses") + check_mode = module.check_mode + + wafv2 = module.client('wafv2') + + change = False + retval = {} + + ip_set = IpSet(wafv2, name, scope, module.fail_json_aws) + + if state == 'present': + if ip_set.get(): + change, addresses = compare(ip_set.get(), addresses, purge_addresses, state) + if (change or ip_set.description() != description) and not check_mode: + retval = ip_set.update( + description=description, + addresses=addresses + ) + else: + retval = ip_set.get() + else: + if not check_mode: + retval = ip_set.create( + description=description, + ip_address_version=ip_address_version, + addresses=addresses, + tags=tags + ) + change = True + + if state == 'absent': + if ip_set.get(): + if addresses: + if len(addresses) > 0: + change, addresses = compare(ip_set.get(), addresses, purge_addresses, state) + if change and not check_mode: + retval = ip_set.update( + description=description, + addresses=addresses + ) + else: + if not check_mode: + retval = ip_set.remove() + change = True + + module.exit_json(changed=change, **retval) + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/wafv2_ip_set_info.py b/plugins/modules/wafv2_ip_set_info.py new file mode 100644 index 00000000000..23b3abed4ec --- /dev/null +++ b/plugins/modules/wafv2_ip_set_info.py @@ -0,0 +1,150 @@ +#!/usr/bin/python +# Copyright: Ansible Project +# 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 + + +DOCUMENTATION = ''' +--- +module: wafv2_ip_set_info +version_added: 1.5.0 +author: + - "Markus Bergholz (@markuman)" +short_description: Get information about wafv2 ip sets +description: + - Get information about existing wafv2 ip sets. +requirements: + - boto3 + - botocore +options: + name: + description: + - The name of the IP set. + required: true + type: str + scope: + description: + - Specifies whether this is for an AWS CloudFront distribution or for a regional application. + choices: ["CLOUDFRONT","REGIONAL"] + required: true + type: str + +extends_documentation_fragment: +- amazon.aws.aws +- amazon.aws.ec2 + +''' + +EXAMPLES = ''' +- name: test ip set + wafv2_ip_set_info: + name: test02 + scope: REGIONAL +''' + +RETURN = """ +addresses: + description: Current addresses of the ip set + sample: + - 8.8.8.8/32 + - 8.8.4.4/32 + returned: Always, as long as the ip set exists + type: list +arn: + description: IP set arn + sample: "arn:aws:wafv2:eu-central-1:11111111:regional/ipset/test02/4b007330-2934-4dc5-af24-82dcb3aeb127" + type: str + returned: Always, as long as the ip set exists +description: + description: Description of the ip set + sample: Some IP set description + returned: Always, as long as the ip set exists + type: str +ip_address_version: + description: IP version of the ip set + sample: IPV4 + type: str + returned: Always, as long as the ip set exists +name: + description: IP set name + sample: test02 + returned: Always, as long as the ip set exists + type: str +""" +from ansible_collections.amazon.aws.plugins.module_utils.core import AnsibleAWSModule, is_boto3_error_code, get_boto3_client_method_parameters +from ansible_collections.amazon.aws.plugins.module_utils.ec2 import camel_dict_to_snake_dict + +try: + from botocore.exceptions import ClientError, BotoCoreError +except ImportError: + pass # caught by AnsibleAWSModule + + +def list_ip_sets(wafv2, scope, fail_json_aws, Nextmarker=None): + # there is currently no paginator for wafv2 + req_obj = { + 'Scope': scope, + 'Limit': 100 + } + if Nextmarker: + req_obj['NextMarker'] = Nextmarker + + try: + response = wafv2.list_ip_sets(**req_obj) + if response.get('NextMarker'): + response['IPSets'] += list_ip_sets(wafv2, scope, fail_json_aws, Nextmarker=response.get('NextMarker')).get('IPSets') + except (BotoCoreError, ClientError) as e: + fail_json_aws(e, msg="Failed to list wafv2 ip set.") + return response + + +def get_ip_set(wafv2, name, scope, id, fail_json_aws): + try: + response = wafv2.get_ip_set( + Name=name, + Scope=scope, + Id=id + ) + except (BotoCoreError, ClientError) as e: + fail_json_aws(e, msg="Failed to get wafv2 ip set.") + return response + + +def main(): + + arg_spec = dict( + name=dict(type='str', required=True), + scope=dict(type='str', required=True, choices=['CLOUDFRONT', 'REGIONAL']) + ) + + module = AnsibleAWSModule( + argument_spec=arg_spec, + supports_check_mode=True, + ) + + name = module.params.get("name") + scope = module.params.get("scope") + + wafv2 = module.client('wafv2') + + # check if ip set exist + response = list_ip_sets(wafv2, scope, module.fail_json_aws) + + id = None + + for item in response.get('IPSets'): + if item.get('Name') == name: + id = item.get('Id') + + retval = {} + existing_set = None + if id: + existing_set = get_ip_set(wafv2, name, scope, id, module.fail_json_aws) + retval = camel_dict_to_snake_dict(existing_set.get('IPSet')) + + module.exit_json(**retval) + + +if __name__ == '__main__': + main() diff --git a/tests/integration/targets/wafv2_ip_set/aliases b/tests/integration/targets/wafv2_ip_set/aliases new file mode 100644 index 00000000000..12354953063 --- /dev/null +++ b/tests/integration/targets/wafv2_ip_set/aliases @@ -0,0 +1,5 @@ +cloud/aws +shippable/aws/group1 + +wafv2_ip_set +wafv2_ip_set_info diff --git a/tests/integration/targets/wafv2_ip_set/defaults/main.yml b/tests/integration/targets/wafv2_ip_set/defaults/main.yml new file mode 100644 index 00000000000..11f2faed964 --- /dev/null +++ b/tests/integration/targets/wafv2_ip_set/defaults/main.yml @@ -0,0 +1,2 @@ +--- +ip_set_name: '{{ resource_prefix }}-ipset' diff --git a/tests/integration/targets/wafv2_ip_set/tasks/main.yml b/tests/integration/targets/wafv2_ip_set/tasks/main.yml new file mode 100644 index 00000000000..6436fc81cc4 --- /dev/null +++ b/tests/integration/targets/wafv2_ip_set/tasks/main.yml @@ -0,0 +1,213 @@ +--- +- module_defaults: + group/aws: + aws_access_key: "{{ aws_access_key }}" + aws_secret_key: "{{ aws_secret_key }}" + security_token: "{{ security_token | default(omit) }}" + region: "{{ aws_region }}" + + block: + - name: check_mode create ip set + wafv2_ip_set: + name: "{{ ip_set_name }}" + state: present + description: hallo eins + scope: REGIONAL + ip_address_version: IPV4 + addresses: + - 8.8.8.8/32 + - 8.8.4.4/32 + tags: + A: B + C: D + register: out + check_mode: yes + + - name: verify create + assert: + that: + - out is changed + + - name: create ip set + wafv2_ip_set: + name: "{{ ip_set_name }}" + state: present + description: hallo eins + scope: REGIONAL + ip_address_version: IPV4 + addresses: + - 8.8.8.8/32 + - 8.8.4.4/32 + tags: + A: B + C: D + register: out + + - name: verify create + assert: + that: + - out is changed + - "'8.8.8.8/32' in out.addresses" + - out.ip_address_version == 'IPV4' + - out.addresses | count == 2 + - out.description == 'hallo eins' + + - name: change ip set + wafv2_ip_set: + name: "{{ ip_set_name }}" + state: present + description: hallo eins + scope: REGIONAL + ip_address_version: IPV4 + addresses: + - 8.8.8.8/32 + - 8.8.4.4/32 + - 10.0.0.0/8 + tags: + A: B + C: D + register: out + + - name: verify create + assert: + that: + - out is changed + - "'10.0.0.0/8' in out.addresses" + + - name: test ip set immutable + wafv2_ip_set: + name: "{{ ip_set_name }}" + state: present + description: hallo eins + scope: REGIONAL + ip_address_version: IPV4 + addresses: + - 8.8.8.8/32 + - 8.8.4.4/32 + - 10.0.0.0/8 + tags: + A: B + C: D + register: out + + - name: verify immutable create + assert: + that: + - out is not changed + - out.addresses | count == 3 + + - name: add one ip + wafv2_ip_set: + name: "{{ ip_set_name }}" + state: present + description: hallo eins + scope: REGIONAL + ip_address_version: IPV4 + purge_addresses: no + addresses: + - 127.0.0.1/32 + register: out + + - name: verify change + assert: + that: + - out is changed + - out.addresses | count == 4 + - "'127.0.0.1/32' in out.addresses" + + + - name: remove one ip + wafv2_ip_set: + name: "{{ ip_set_name }}" + state: absent + description: hallo eins + scope: REGIONAL + ip_address_version: IPV4 + purge_addresses: yes + addresses: + - 127.0.0.1/32 + register: out + + - name: verify change + assert: + that: + - out is changed + - out.addresses | count == 3 + - "'127.0.0.1/32' not in out.addresses" + - "'8.8.8.8/32' in out.addresses" + + - name: get ip set info + wafv2_ip_set_info: + name: "{{ ip_set_name }}" + scope: REGIONAL + register: out + + - name: verify rules + assert: + that: + - out.addresses | count == 3 + + + - name: purge all but one + wafv2_ip_set: + name: "{{ ip_set_name }}" + state: present + description: hallo eins + scope: REGIONAL + ip_address_version: IPV4 + purge_addresses: yes + addresses: + - 127.0.0.1/32 + register: out + + - name: verify change + assert: + that: + - out is changed + - out.addresses | count == 1 + + - name: get ip set info + wafv2_ip_set_info: + name: "{{ ip_set_name }}" + scope: REGIONAL + register: out + + - name: verify rules + assert: + that: + - out.addresses | count == 1 + + - name: delete ip set + wafv2_ip_set: + name: "{{ ip_set_name }}" + state: absent + scope: REGIONAL + ip_address_version: IPV4 + register: out + + - name: verify delete + assert: + that: + - out is changed + + - name: delete ip set immutable + wafv2_ip_set: + name: "{{ ip_set_name }}" + state: absent + scope: REGIONAL + ip_address_version: IPV4 + register: out + + - name: verify immutable delete + assert: + that: + - out is not changed + + + always: + - name: always delete ip set + wafv2_ip_set: + name: "{{ ip_set_name }}" + state: absent + scope: REGIONAL + ip_address_version: IPV4