From 9226c4b0d5b0bb42216f77220969090c61561098 Mon Sep 17 00:00:00 2001 From: quidame Date: Sat, 15 Aug 2020 08:36:07 +0000 Subject: [PATCH] New module: iptables_state (#271) * restart from last state * test (sanity) doc fragment placeholder * test (sanity) remove doc fragment placeholder * remove internal params from DOCUMENTATION * update ignore-2.10.txt * doc: add changelog fragment * shorten changelog fragment * Revert "shorten changelog fragment" This reverts commit f9aea0d1eaefda139fd5b79bd0eb127c09a433fb. * test with posix/group1 * test with posix/group3 * test with posix/group5 * test with posix/group4 * test with posix/group3 * New modules/action plugins automatically get a changelog entry * fix: styles * Revert "remove internal params from DOCUMENTATION" This reverts commit 7d5fcf4b17e4cd5b0afc08fd1bd3fcef5fcaee26. * drop neutral/informative/stateless behaviour * update tasks after changes in module * use FQCN in EXAMPLES * add tests to validate error handling about required params * doc: remove outdated sentence * do not document internal parameters * display timeout value in failure message * remove inapropriate comment * merge results and clean them up only once * conditionally remove tmp path * at least one iteration is required * remove deprecated code * move variables declaration to conditional block * dissociate async and connection timeout * improve warnings (conditions + values) * remove ANSIBLE_METADATA (no more needed); fix typo * update DOCUMENTATION * Drop field 'version_added' (no more needed). * Add a note about check_mode support. * catch early errors before resetting connection and processing the loop * fix typo * change posix group (due to xtables locks); add 'version_added' in doc * update deprecation (replace Ansible 2.12 by community.general 2.0.0) * bump version_added to 1.0.0 * update ignore-2.11.txt * ignore errors for 2.9 as for 2.10 & 2.11 * move action plugin to system/ and replace it by a symlink * remove action-plugin-docs override in tests/sanity/ignore*.txt * update action plugin docstrings * bump version_added to 1.1.0 * use lowercase booleans * extend usage of namespaces to ansible builtin modules (cherry picked from commit 92242d898d4081fa7d832f512651a801f19306d4) --- plugins/action/__init__.py | 0 plugins/action/iptables_state.py | 1 + plugins/action/system/iptables_state.py | 189 ++++++ plugins/modules/iptables_state.py | 1 + plugins/modules/system/iptables_state.py | 637 ++++++++++++++++++ .../targets/iptables_state/aliases | 6 + .../targets/iptables_state/meta/main.yml | 2 + .../targets/iptables_state/tasks/main.yml | 29 + .../iptables_state/tasks/tests/00-basic.yml | 316 +++++++++ .../iptables_state/tasks/tests/01-tables.yml | 299 ++++++++ .../tasks/tests/10-rollback.yml | 199 ++++++ tests/sanity/ignore-2.10.txt | 1 + tests/sanity/ignore-2.11.txt | 1 + tests/sanity/ignore-2.9.txt | 1 + 14 files changed, 1682 insertions(+) create mode 100644 plugins/action/__init__.py create mode 120000 plugins/action/iptables_state.py create mode 100644 plugins/action/system/iptables_state.py create mode 120000 plugins/modules/iptables_state.py create mode 100644 plugins/modules/system/iptables_state.py create mode 100644 tests/integration/targets/iptables_state/aliases create mode 100644 tests/integration/targets/iptables_state/meta/main.yml create mode 100644 tests/integration/targets/iptables_state/tasks/main.yml create mode 100644 tests/integration/targets/iptables_state/tasks/tests/00-basic.yml create mode 100644 tests/integration/targets/iptables_state/tasks/tests/01-tables.yml create mode 100644 tests/integration/targets/iptables_state/tasks/tests/10-rollback.yml diff --git a/plugins/action/__init__.py b/plugins/action/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/plugins/action/iptables_state.py b/plugins/action/iptables_state.py new file mode 120000 index 0000000000..7884b75ae7 --- /dev/null +++ b/plugins/action/iptables_state.py @@ -0,0 +1 @@ +./system/iptables_state.py \ No newline at end of file diff --git a/plugins/action/system/iptables_state.py b/plugins/action/system/iptables_state.py new file mode 100644 index 0000000000..07c6a23dfb --- /dev/null +++ b/plugins/action/system/iptables_state.py @@ -0,0 +1,189 @@ +# Copyright: (c) 2020, quidame +# 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 + +import time + +from ansible.plugins.action import ActionBase +from ansible.errors import AnsibleError, AnsibleActionFail, AnsibleConnectionFailure +from ansible.utils.vars import merge_hash +from ansible.utils.display import Display + +display = Display() + + +class ActionModule(ActionBase): + + # Keep internal params away from user interactions + _VALID_ARGS = frozenset(('path', 'state', 'table', 'noflush', 'counters', 'modprobe', 'ip_version', 'wait')) + DEFAULT_SUDOABLE = True + + MSG_ERROR__ASYNC_AND_POLL_NOT_ZERO = ( + "This module doesn't support async>0 and poll>0 when its 'state' param " + "is set to 'restored'. To enable its rollback feature (that needs the " + "module to run asynchronously on the remote), please set task attribute " + "'poll' (=%s) to 0, and 'async' (=%s) to a value >2 and not greater than " + "'ansible_timeout' (=%s) (recommended).") + MSG_WARNING__NO_ASYNC_IS_NO_ROLLBACK = ( + "Attempts to restore iptables state without rollback in case of mistake " + "may lead the ansible controller to loose access to the hosts and never " + "regain it before fixing firewall rules through a serial console, or any " + "other way except SSH. Please set task attribute 'poll' (=%s) to 0, and " + "'async' (=%s) to a value >2 and not greater than 'ansible_timeout' (=%s) " + "(recommended).") + MSG_WARNING__ASYNC_GREATER_THAN_TIMEOUT = ( + "You attempt to restore iptables state with rollback in case of mistake, " + "but with settings that will lead this rollback to happen AFTER that the " + "controller will reach its own timeout. Please set task attribute 'poll' " + "(=%s) to 0, and 'async' (=%s) to a value >2 and not greater than " + "'ansible_timeout' (=%s) (recommended).") + + def _async_result(self, module_args, task_vars, timeout): + ''' + Retrieve results of the asynchonous task, and display them in place of + the async wrapper results (those with the ansible_job_id key). + ''' + # At least one iteration is required, even if timeout is 0. + for i in range(max(1, timeout)): + async_result = self._execute_module( + module_name='async_status', + module_args=module_args, + task_vars=task_vars, + wrap_async=False) + if async_result['finished'] == 1: + break + time.sleep(min(1, timeout)) + + return async_result + + def run(self, tmp=None, task_vars=None): + + self._supports_check_mode = True + self._supports_async = True + + result = super(ActionModule, self).run(tmp, task_vars) + del tmp # tmp no longer has any effect + + if not result.get('skipped'): + + # FUTURE: better to let _execute_module calculate this internally? + wrap_async = self._task.async_val and not self._connection.has_native_async + + # Set short names for values we'll have to compare or reuse + task_poll = self._task.poll + task_async = self._task.async_val + check_mode = self._play_context.check_mode + max_timeout = self._connection._play_context.timeout + module_name = self._task.action + module_args = self._task.args + + if module_args.get('state', None) == 'restored': + if not wrap_async: + if not check_mode: + display.warning(self.MSG_WARNING__NO_ASYNC_IS_NO_ROLLBACK % ( + task_poll, + task_async, + max_timeout)) + elif task_poll: + raise AnsibleActionFail(self.MSG_ERROR__ASYNC_AND_POLL_NOT_ZERO % ( + task_poll, + task_async, + max_timeout)) + else: + if task_async > max_timeout and not check_mode: + display.warning(self.MSG_WARNING__ASYNC_GREATER_THAN_TIMEOUT % ( + task_poll, + task_async, + max_timeout)) + + # BEGIN snippet from async_status action plugin + env_async_dir = [e for e in self._task.environment if + "ANSIBLE_ASYNC_DIR" in e] + if len(env_async_dir) > 0: + # for backwards compatibility we need to get the dir from + # ANSIBLE_ASYNC_DIR that is defined in the environment. This is + # deprecated and will be removed in favour of shell options + async_dir = env_async_dir[0]['ANSIBLE_ASYNC_DIR'] + + msg = "Setting the async dir from the environment keyword " \ + "ANSIBLE_ASYNC_DIR is deprecated. Set the async_dir " \ + "shell option instead" + display.deprecated(msg, version='2.0.0', + collection_name='community.general') # was Ansible 2.12 + else: + # inject the async directory based on the shell option into the + # module args + async_dir = self.get_shell_option('async_dir', default="~/.ansible_async") + # END snippet from async_status action plugin + + # Bind the loop max duration to consistent values on both + # remote and local sides (if not the same, make the loop + # longer on the controller); and set a backup file path. + module_args['_timeout'] = task_async + module_args['_back'] = '%s/iptables.state' % async_dir + async_status_args = dict(_async_dir=async_dir) + confirm_cmd = 'rm -f %s' % module_args['_back'] + remaining_time = max(task_async, max_timeout) + + # do work! + result = merge_hash(result, self._execute_module(module_args=module_args, task_vars=task_vars, wrap_async=wrap_async)) + + # Then the 3-steps "go ahead or rollback": + # - reset connection to ensure a persistent one will not be reused + # - confirm the restored state by removing the backup on the remote + # - retrieve the results of the asynchronous task to return them + if '_back' in module_args: + async_status_args['jid'] = result.get('ansible_job_id', None) + if async_status_args['jid'] is None: + raise AnsibleActionFail("Unable to get 'ansible_job_id'.") + + # Catch early errors due to missing mandatory option, bad + # option type/value, missing required system command, etc. + result = merge_hash(result, self._async_result(async_status_args, task_vars, 0)) + + if not result['finished']: + try: + self._connection.reset() + display.v("%s: reset connection" % (module_name)) + except AttributeError: + display.warning("Connection plugin does not allow to reset the connection.") + + for x in range(max_timeout): + time.sleep(1) + remaining_time -= 1 + # - AnsibleConnectionFailure covers rejected requests (i.e. + # by rules with '--jump REJECT') + # - ansible_timeout is able to cover dropped requests (due + # to a rule or policy DROP) if not lower than async_val. + try: + garbage = self._low_level_execute_command(confirm_cmd, sudoable=self.DEFAULT_SUDOABLE) + break + except AnsibleConnectionFailure: + continue + + result = merge_hash(result, self._async_result(async_status_args, task_vars, remaining_time)) + + # Cleanup async related stuff and internal params + for key in ('ansible_job_id', 'results_file', 'started', 'finished'): + if result.get(key): + del result[key] + + if result.get('invocation', {}).get('module_args'): + if '_timeout' in result['invocation']['module_args']: + del result['invocation']['module_args']['_back'] + del result['invocation']['module_args']['_timeout'] + + async_status_args['mode'] = 'cleanup' + garbage = self._execute_module( + module_name='async_status', + module_args=async_status_args, + task_vars=task_vars, + wrap_async=False) + + if not wrap_async: + # remove a temporary path we created + self._remove_tmp_path(self._connection._shell.tmpdir) + + return result diff --git a/plugins/modules/iptables_state.py b/plugins/modules/iptables_state.py new file mode 120000 index 0000000000..864608d532 --- /dev/null +++ b/plugins/modules/iptables_state.py @@ -0,0 +1 @@ +system/iptables_state.py \ No newline at end of file diff --git a/plugins/modules/system/iptables_state.py b/plugins/modules/system/iptables_state.py new file mode 100644 index 0000000000..a7dd073c8c --- /dev/null +++ b/plugins/modules/system/iptables_state.py @@ -0,0 +1,637 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2020, quidame +# 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 = r''' +--- +module: iptables_state +short_description: Save iptables state into a file or restore it from a file +version_added: '1.1.0' +author: quidame (@quidame) +description: + - C(iptables) is used to set up, maintain, and inspect the tables of IP + packet filter rules in the Linux kernel. + - This module handles the saving and/or loading of rules. This is the same + as the behaviour of the C(iptables-save) and C(iptables-restore) (or + C(ip6tables-save) and C(ip6tables-restore) for IPv6) commands which this + module uses internally. + - Modifying the state of the firewall remotely may lead to loose access to + the host in case of mistake in new ruleset. This module embeds a rollback + feature to avoid this, by telling the host to restore previous rules if a + cookie is still there after a given delay, and all this time telling the + controller to try to remove this cookie on the host through a new + connection. +notes: + - The rollback feature is not a module option and depends on task's + attributes. To enable it, the module must be played asynchronously, i.e. + by setting task attributes I(poll) to C(0), and I(async) to a value less + or equal to C(ANSIBLE_TIMEOUT). If I(async) is greater, the rollback will + still happen if it shall happen, but you will experience a connection + timeout instead of more relevant info returned by the module after its + failure. + - This module supports I(check_mode). +options: + counters: + description: + - Save or restore the values of all packet and byte counters. + - When C(true), the module is not idempotent. + type: bool + default: false + ip_version: + description: + - Which version of the IP protocol this module should apply to. + type: str + choices: [ ipv4, ipv6 ] + default: ipv4 + modprobe: + description: + - Specify the path to the C(modprobe) program internally used by iptables + related commands to load kernel modules. + - By default, C(/proc/sys/kernel/modprobe) is inspected to determine the + executable's path. + type: path + noflush: + description: + - For I(state=restored), ignored otherwise. + - If C(false), restoring iptables rules from a file flushes (deletes) + all previous contents of the respective table(s). If C(true), the + previous rules are left untouched (but policies are updated anyway, + for all built-in chains). + type: bool + default: false + path: + description: + - The file the iptables state should be saved to. + - The file the iptables state should be restored from. + type: path + required: yes + state: + description: + - Whether the firewall state should be saved (into a file) or restored + (from a file). + type: str + choices: [ saved, restored ] + required: yes + table: + description: + - When I(state=restored), restore only the named table even if the input + file contains other tables. Fail if the named table is not declared in + the file. + - When I(state=saved), restrict output to the specified table. If not + specified, output includes all active tables. + type: str + choices: [ filter, nat, mangle, raw, security ] + wait: + description: + - Wait N seconds for the xtables lock to prevent instant failure in case + multiple instances of the program are running concurrently. + type: int +requirements: [iptables, ip6tables] +''' + +EXAMPLES = r''' +# This will apply to all loaded/active IPv4 tables. +- name: Save current state of the firewall in system file + community.general.iptables_state: + state: saved + path: /etc/sysconfig/iptables + +# This will apply only to IPv6 filter table. +- name: save current state of the firewall in system file + community.general.iptables_state: + ip_version: ipv6 + table: filter + state: saved + path: /etc/iptables/rules.v6 + +# This will load a state from a file, with a rollback in case of access loss +- name: restore firewall state from a file + community.general.iptables_state: + state: restored + path: /run/iptables.apply + async: "{{ ansible_timeout }}" + poll: 0 + +# This will load new rules by appending them to the current ones +- name: restore firewall state from a file + community.general.iptables_state: + state: restored + path: /run/iptables.apply + noflush: true + async: "{{ ansible_timeout }}" + poll: 0 + +# This will only retrieve information +- name: get current state of the firewall + community.general.iptables_state: + state: saved + path: /tmp/iptables + check_mode: yes + changed_when: false + register: iptables_state + +- name: show current state of the firewall + ansible.builtin.debug: + var: iptables_state.initial_state +''' + +RETURN = r''' +applied: + description: Whether or not the wanted state has been successfully restored. + type: bool + returned: always + sample: true +initial_state: + description: The current state of the firewall when module starts. + type: list + elements: str + returned: always + sample: [ + "# Generated by xtables-save v1.8.2", + "*filter", + ":INPUT ACCEPT [0:0]", + ":FORWARD ACCEPT [0:0]", + ":OUTPUT ACCEPT [0:0]", + "COMMIT", + "# Completed" + ] +restored: + description: The state the module restored, whenever it is finally applied or not. + type: list + elements: str + returned: always + sample: [ + "# Generated by xtables-save v1.8.2", + "*filter", + ":INPUT DROP [0:0]", + ":FORWARD DROP [0:0]", + ":OUTPUT ACCEPT [0:0]", + "-A INPUT -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT", + "-A INPUT -m conntrack --ctstate INVALID -j DROP", + "-A INPUT -i lo -j ACCEPT", + "-A INPUT -p icmp -j ACCEPT", + "-A INPUT -p tcp -m tcp --dport 22 -j ACCEPT", + "COMMIT", + "# Completed" + ] +saved: + description: The iptables state the module saved. + type: list + elements: str + returned: always + sample: [ + "# Generated by xtables-save v1.8.2", + "*filter", + ":INPUT ACCEPT [0:0]", + ":FORWARD DROP [0:0]", + ":OUTPUT ACCEPT [0:0]", + "COMMIT", + "# Completed" + ] +tables: + description: The iptables we have interest for when module starts. + type: dict + contains: + table: + description: Policies and rules for all chains of the named table. + type: list + elements: str + sample: |- + { + "filter": [ + ":INPUT ACCEPT", + ":FORWARD ACCEPT", + ":OUTPUT ACCEPT", + "-A INPUT -i lo -j ACCEPT", + "-A INPUT -p icmp -j ACCEPT", + "-A INPUT -p tcp -m tcp --dport 22 -j ACCEPT", + "-A INPUT -j REJECT --reject-with icmp-host-prohibited" + ], + "nat": [ + ":PREROUTING ACCEPT", + ":INPUT ACCEPT", + ":OUTPUT ACCEPT", + ":POSTROUTING ACCEPT" + ] + } + returned: always +''' + + +import re +import os +import time +import tempfile +import filecmp +import shutil + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils._text import to_bytes, to_native, to_text + + +IPTABLES = dict( + ipv4='iptables', + ipv6='ip6tables', +) + +SAVE = dict( + ipv4='iptables-save', + ipv6='ip6tables-save', +) + +RESTORE = dict( + ipv4='iptables-restore', + ipv6='ip6tables-restore', +) + +TABLES = ['filter', 'mangle', 'nat', 'raw', 'security'] + + +def read_state(b_path): + ''' + Read a file and store its content in a variable as a list. + ''' + with open(b_path, 'r') as f: + text = f.read() + lines = text.splitlines() + while '' in lines: + lines.remove('') + return (lines) + + +def write_state(b_path, lines, changed): + ''' + Write given contents to the given path, and return changed status. + ''' + # Populate a temporary file + tmpfd, tmpfile = tempfile.mkstemp() + with os.fdopen(tmpfd, 'w') as f: + for line in lines: + f.write('%s\n' % line) + + # Prepare to copy temporary file to the final destination + if not os.path.exists(b_path): + b_destdir = os.path.dirname(b_path) + destdir = to_native(b_destdir, errors='surrogate_or_strict') + if b_destdir and not os.path.exists(b_destdir) and not module.check_mode: + try: + os.makedirs(b_destdir) + except Exception as e: + module.fail_json( + msg='Error creating %s. Error code: %s. Error description: %s' % (destdir, e[0], e[1]), + initial_state=lines) + changed = True + + elif not filecmp.cmp(tmpfile, b_path): + changed = True + + # Do it + if changed and not module.check_mode: + try: + shutil.copyfile(tmpfile, b_path) + except Exception as e: + path = to_native(b_path, errors='surrogate_or_strict') + module.fail_json( + msg='Error saving state into %s. Error code: %s. Error description: %s' % (path, e[0], e[1]), + initial_state=lines) + + return changed + + +def initialize_from_null_state(initializer, initcommand, table): + ''' + This ensures iptables-state output is suitable for iptables-restore to roll + back to it, i.e. iptables-save output is not empty. This also works for the + iptables-nft-save alternative. + ''' + if table is None: + table = 'filter' + + tmpfd, tmpfile = tempfile.mkstemp() + with os.fdopen(tmpfd, 'w') as f: + f.write('*%s\nCOMMIT\n' % table) + + initializer.append(tmpfile) + (rc, out, err) = module.run_command(initializer, check_rc=True) + (rc, out, err) = module.run_command(initcommand, check_rc=True) + return (rc, out, err) + + +def filter_and_format_state(string): + ''' + Remove timestamps to ensure idempotence between runs. Also remove counters + by default. And return the result as a list. + ''' + string = re.sub('((^|\n)# (Generated|Completed)[^\n]*) on [^\n]*', '\\1', string) + if not module.params['counters']: + string = re.sub('[[][0-9]+:[0-9]+[]]', '[0:0]', string) + lines = string.splitlines() + while '' in lines: + lines.remove('') + return (lines) + + +def per_table_state(command, state): + ''' + Convert raw iptables-save output into usable datastructure, for reliable + comparisons between initial and final states. + ''' + tables = dict() + for t in TABLES: + COMMAND = list(command) + if '*%s' % t in state.splitlines(): + COMMAND.extend(['--table', t]) + (rc, out, err) = module.run_command(COMMAND, check_rc=True) + out = re.sub('(^|\n)(# Generated|# Completed|[*]%s|COMMIT)[^\n]*' % t, '', out) + out = re.sub(' *[[][0-9]+:[0-9]+[]] *', '', out) + table = out.splitlines() + while '' in table: + table.remove('') + tables[t] = table + return (tables) + + +def main(): + + global module + + module = AnsibleModule( + argument_spec=dict( + path=dict(type='path', required=True), + state=dict(type='str', choices=['saved', 'restored'], required=True), + table=dict(type='str', choices=['filter', 'nat', 'mangle', 'raw', 'security']), + noflush=dict(type='bool', default=False), + counters=dict(type='bool', default=False), + modprobe=dict(type='path'), + ip_version=dict(type='str', choices=['ipv4', 'ipv6'], default='ipv4'), + wait=dict(type='int'), + _timeout=dict(type='int'), + _back=dict(type='path'), + ), + required_together=[ + ['_timeout', '_back'], + ], + supports_check_mode=True, + ) + + # We'll parse iptables-restore stderr + module.run_command_environ_update = dict(LANG='C', LC_MESSAGES='C') + + path = module.params['path'] + state = module.params['state'] + table = module.params['table'] + noflush = module.params['noflush'] + counters = module.params['counters'] + modprobe = module.params['modprobe'] + ip_version = module.params['ip_version'] + wait = module.params['wait'] + _timeout = module.params['_timeout'] + _back = module.params['_back'] + + bin_iptables = module.get_bin_path(IPTABLES[ip_version], True) + bin_iptables_save = module.get_bin_path(SAVE[ip_version], True) + bin_iptables_restore = module.get_bin_path(RESTORE[ip_version], True) + + os.umask(0o077) + changed = False + COMMANDARGS = [] + INITCOMMAND = [bin_iptables_save] + INITIALIZER = [bin_iptables_restore] + TESTCOMMAND = [bin_iptables_restore, '--test'] + + if counters: + COMMANDARGS.append('--counters') + + if table is not None: + COMMANDARGS.extend(['--table', table]) + + if wait is not None: + TESTCOMMAND.extend(['--wait', '%s' % wait]) + + if modprobe is not None: + b_modprobe = to_bytes(modprobe, errors='surrogate_or_strict') + if not os.path.exists(b_modprobe): + module.fail_json(msg="modprobe %s not found" % modprobe) + if not os.path.isfile(b_modprobe): + module.fail_json(msg="modprobe %s not a file" % modprobe) + if not os.access(b_modprobe, os.R_OK): + module.fail_json(msg="modprobe %s not readable" % modprobe) + if not os.access(b_modprobe, os.X_OK): + module.fail_json(msg="modprobe %s not executable" % modprobe) + COMMANDARGS.extend(['--modprobe', modprobe]) + INITIALIZER.extend(['--modprobe', modprobe]) + INITCOMMAND.extend(['--modprobe', modprobe]) + TESTCOMMAND.extend(['--modprobe', modprobe]) + + SAVECOMMAND = list(COMMANDARGS) + SAVECOMMAND.insert(0, bin_iptables_save) + + b_path = to_bytes(path, errors='surrogate_or_strict') + + if state == 'restored': + if not os.path.exists(b_path): + module.fail_json(msg="Source %s not found" % path) + if not os.path.isfile(b_path): + module.fail_json(msg="Source %s not a file" % path) + if not os.access(b_path, os.R_OK): + module.fail_json(msg="Source %s not readable" % path) + state_to_restore = read_state(b_path) + else: + cmd = ' '.join(SAVECOMMAND) + + (rc, stdout, stderr) = module.run_command(INITCOMMAND, check_rc=True) + + # The issue comes when wanting to restore state from empty iptable-save's + # output... what happens when, say: + # - no table is specified, and iptables-save's output is only nat table; + # - we give filter's ruleset to iptables-restore, that locks ourselve out + # of the host; + # then trying to roll iptables state back to the previous (working) setup + # doesn't override current filter table because no filter table is stored + # in the backup ! So we have to ensure tables to be restored have a backup + # in case of rollback. + if table is None: + if state == 'restored': + for t in TABLES: + if '*%s' % t in state_to_restore: + if len(stdout) == 0 or '*%s' % t not in stdout.splitlines(): + (rc, stdout, stderr) = initialize_from_null_state(INITIALIZER, INITCOMMAND, t) + elif len(stdout) == 0: + (rc, stdout, stderr) = initialize_from_null_state(INITIALIZER, INITCOMMAND, 'filter') + + elif state == 'restored' and '*%s' % table not in state_to_restore: + module.fail_json(msg="Table %s to restore not defined in %s" % (table, path)) + + elif len(stdout) == 0 or '*%s' % table not in stdout.splitlines(): + (rc, stdout, stderr) = initialize_from_null_state(INITIALIZER, INITCOMMAND, table) + + initial_state = filter_and_format_state(stdout) + if initial_state is None: + module.fail_json(msg="Unable to initialize firewall from NULL state.") + + # Depending on the value of 'table', initref_state may differ from + # initial_state. + (rc, stdout, stderr) = module.run_command(SAVECOMMAND, check_rc=True) + tables_before = per_table_state(SAVECOMMAND, stdout) + initref_state = filter_and_format_state(stdout) + + if state == 'saved': + changed = write_state(b_path, initref_state, changed) + module.exit_json( + changed=changed, + cmd=cmd, + tables=tables_before, + initial_state=initial_state, + saved=initref_state) + + # + # All remaining code is for state=restored + # + + MAINCOMMAND = list(COMMANDARGS) + MAINCOMMAND.insert(0, bin_iptables_restore) + + if wait is not None: + MAINCOMMAND.extend(['--wait', '%s' % wait]) + + if _back is not None: + b_back = to_bytes(_back, errors='surrogate_or_strict') + garbage = write_state(b_back, initref_state, changed) + BACKCOMMAND = list(MAINCOMMAND) + BACKCOMMAND.append(_back) + + if noflush: + MAINCOMMAND.append('--noflush') + + MAINCOMMAND.append(path) + cmd = ' '.join(MAINCOMMAND) + + TESTCOMMAND = list(MAINCOMMAND) + TESTCOMMAND.insert(1, '--test') + error_msg = "Source %s is not suitable for input to %s" % (path, os.path.basename(bin_iptables_restore)) + + # Due to a bug in iptables-nft-restore --test, we have to validate tables + # one by one (https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=960003). + for t in tables_before: + testcommand = list(TESTCOMMAND) + testcommand.extend(['--table', t]) + (rc, stdout, stderr) = module.run_command(testcommand) + + if 'Another app is currently holding the xtables lock' in stderr: + error_msg = stderr + + if rc != 0: + cmd = ' '.join(testcommand) + module.fail_json( + msg=error_msg, + cmd=cmd, + rc=rc, + stdout=stdout, + stderr=stderr, + tables=tables_before, + initial_state=initial_state, + restored=state_to_restore, + applied=False) + + if module.check_mode: + tmpfd, tmpfile = tempfile.mkstemp() + with os.fdopen(tmpfd, 'w') as f: + for line in initial_state: + f.write('%s\n' % line) + + if filecmp.cmp(tmpfile, b_path): + restored_state = initial_state + else: + restored_state = state_to_restore + + else: + (rc, stdout, stderr) = module.run_command(MAINCOMMAND) + if 'Another app is currently holding the xtables lock' in stderr: + module.fail_json( + msg=stderr, + cmd=cmd, + rc=rc, + stdout=stdout, + stderr=stderr, + tables=tables_before, + initial_state=initial_state, + restored=state_to_restore, + applied=False) + + (rc, stdout, stderr) = module.run_command(SAVECOMMAND, check_rc=True) + restored_state = filter_and_format_state(stdout) + + if restored_state != initref_state and restored_state != initial_state: + if module.check_mode: + changed = True + else: + tables_after = per_table_state(SAVECOMMAND, stdout) + if tables_after != tables_before: + changed = True + + if _back is None or module.check_mode: + module.exit_json( + changed=changed, + cmd=cmd, + tables=tables_before, + initial_state=initial_state, + restored=restored_state, + applied=True) + + # The rollback implementation currently needs: + # Here: + # * test existence of the backup file, exit with success if it doesn't exist + # * otherwise, restore iptables from this file and return failure + # Action plugin: + # * try to remove the backup file + # * wait async task is finished and retrieve its final status + # * modify it and return the result + # Task: + # * task attribute 'async' set to the same value (or lower) than ansible + # timeout + # * task attribute 'poll' equals 0 + # + for x in range(_timeout): + if os.path.exists(b_back): + time.sleep(1) + continue + module.exit_json( + changed=changed, + cmd=cmd, + tables=tables_before, + initial_state=initial_state, + restored=restored_state, + applied=True) + + # Here we are: for whatever reason, but probably due to the current ruleset, + # the action plugin (i.e. on the controller) was unable to remove the backup + # cookie, so we restore initial state from it. + (rc, stdout, stderr) = module.run_command(BACKCOMMAND, check_rc=True) + os.remove(b_back) + + (rc, stdout, stderr) = module.run_command(SAVECOMMAND, check_rc=True) + tables_rollback = per_table_state(SAVECOMMAND, stdout) + + msg = ( + "Failed to confirm state restored from %s after %ss. " + "Firewall has been rolled back to its initial state." % (path, _timeout) + ) + + module.fail_json( + changed=(tables_before != tables_rollback), + msg=msg, + cmd=cmd, + tables=tables_before, + initial_state=initial_state, + restored=restored_state, + applied=False) + + +if __name__ == '__main__': + main() diff --git a/tests/integration/targets/iptables_state/aliases b/tests/integration/targets/iptables_state/aliases new file mode 100644 index 0000000000..8ae302a2ac --- /dev/null +++ b/tests/integration/targets/iptables_state/aliases @@ -0,0 +1,6 @@ +destructive +shippable/posix/group4 +skip/docker # kernel modules not loadable +skip/freebsd # no iptables/netfilter (Linux specific) +skip/osx # no iptables/netfilter (Linux specific) +skip/aix # no iptables/netfilter (Linux specific) diff --git a/tests/integration/targets/iptables_state/meta/main.yml b/tests/integration/targets/iptables_state/meta/main.yml new file mode 100644 index 0000000000..5438ced5c3 --- /dev/null +++ b/tests/integration/targets/iptables_state/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: + - setup_pkg_mgr diff --git a/tests/integration/targets/iptables_state/tasks/main.yml b/tests/integration/targets/iptables_state/tasks/main.yml new file mode 100644 index 0000000000..a50049dd9f --- /dev/null +++ b/tests/integration/targets/iptables_state/tasks/main.yml @@ -0,0 +1,29 @@ +--- +- name: ensure iptables package is installed + package: + name: + - iptables + become: yes + + +- name: include tasks + vars: + iptables_saved: "/tmp/test_iptables_state.saved" + iptables_tests: "/tmp/test_iptables_state.tests" + + block: + - name: include tasks to perform basic tests (check_mode, async, idempotency) + include_tasks: tests/00-basic.yml + + - name: include tasks to test tables handling + include_tasks: tests/01-tables.yml + when: + - xtables_lock is undefined + + - name: include tasks to test rollbacks + include_tasks: tests/10-rollback.yml + when: + - xtables_lock is undefined + - ansible_connection in ['ssh', 'paramiko', 'smart'] + + become: yes diff --git a/tests/integration/targets/iptables_state/tasks/tests/00-basic.yml b/tests/integration/targets/iptables_state/tasks/tests/00-basic.yml new file mode 100644 index 0000000000..fcd259ec55 --- /dev/null +++ b/tests/integration/targets/iptables_state/tasks/tests/00-basic.yml @@ -0,0 +1,316 @@ +--- +- name: "ensure our next backup is not there (file)" + file: + path: "{{ iptables_saved }}" + state: absent + +- name: "ensure our next rule is not there (iptables)" + iptables: + chain: OUTPUT + jump: ACCEPT + state: absent + + +# +# Basic checks about invalid param/value handling. +# +- name: "trigger error about invalid param" + iptables_state: + name: foobar + register: iptables_state + ignore_errors: yes + +- name: "assert that results are as expected" + assert: + that: + - iptables_state is failed + - iptables_state.msg is match("Invalid options") + quiet: yes + + + +- name: "trigger error about missing param 'state'" + iptables_state: + path: foobar + register: iptables_state + ignore_errors: yes + +- name: "assert that results are as expected" + assert: + that: + - iptables_state is failed + - iptables_state.msg is match("missing required arguments") + quiet: yes + + + +- name: "trigger error about missing param 'path'" + iptables_state: + state: saved + register: iptables_state + ignore_errors: yes + +- name: "assert that results are as expected" + assert: + that: + - iptables_state is failed + - iptables_state.msg is match("missing required arguments") + quiet: yes + + + +- name: "trigger error about invalid value for param 'state'" + iptables_state: + path: foobar + state: present + register: iptables_state + ignore_errors: yes + +- name: "assert that results are as expected" + assert: + that: + - iptables_state is failed + - iptables_state.msg is match("value of state must be one of") + quiet: yes + + +# +# Play with the current state first. We will create a file to store it in, but +# no more. These tests are for: +# - idempotency +# - check_mode +# +- name: "save state (check_mode, must report a change)" + iptables_state: + path: "{{ iptables_saved }}" + state: saved + register: iptables_state + check_mode: yes + +- name: "assert that results are as expected" + assert: + that: + - iptables_state is changed + - iptables_state.initial_state == iptables_state.saved + quiet: yes + + + +- name: "save state (must report a change)" + iptables_state: + path: "{{ iptables_saved }}" + state: saved + register: iptables_state + +- name: "assert that results are as expected" + assert: + that: + - iptables_state is changed + - iptables_state.initial_state == iptables_state.saved + quiet: yes + + + +- name: "save state (idempotency, must NOT report a change)" + iptables_state: + path: "{{ iptables_saved }}" + state: saved + register: iptables_state + +- name: "assert that results are as expected" + assert: + that: + - iptables_state is not changed + - iptables_state.initial_state == iptables_state.saved + quiet: yes + + + +- name: "save state (check_mode, must NOT report a change)" + iptables_state: + path: "{{ iptables_saved }}" + state: saved + register: iptables_state + check_mode: yes + +- name: "assert that results are as expected" + assert: + that: + - iptables_state is not changed + - iptables_state.initial_state == iptables_state.saved + quiet: yes + + + +# We begin with 'state=restored' by restoring the current state on itself. +# This at least ensures the file produced with state=saved is suitable for +# state=restored. + +- name: "state=restored check_mode=true changed=false" + block: + - name: "restore state (check_mode, must NOT report a change, no warning)" + iptables_state: + path: "{{ iptables_saved }}" + state: restored + register: iptables_state + check_mode: yes + + - name: "assert that results are as expected" + assert: + that: + - iptables_state is not changed + - iptables_state.initial_state == iptables_state.restored + quiet: yes + + rescue: + - name: "assert that results are not as expected for only one reason (xtables lock)" + assert: + that: + - iptables_state is failed + - iptables_state.stderr is search('xtables lock') + quiet: yes + register: xtables_lock + + + +- name: "state=restored changed=false" + block: + - name: "restore state (must NOT report a change, warning about rollback & async)" + iptables_state: + path: "{{ iptables_saved }}" + state: restored + register: iptables_state + + - name: "assert that results are as expected" + assert: + that: + - iptables_state is not changed + - iptables_state.initial_state == iptables_state.restored + quiet: yes + + rescue: + - name: "assert that results are not as expected for only one reason (xtables lock)" + assert: + that: + - iptables_state is failed + - iptables_state.stderr is search('xtables lock') + quiet: yes + register: xtables_lock + + + +- name: "change iptables state (iptables)" + iptables: + chain: OUTPUT + jump: ACCEPT + + + +- name: "state=restored changed=true" + block: + - name: "restore state (check_mode, must report a change)" + iptables_state: + path: "{{ iptables_saved }}" + state: restored + register: iptables_state + check_mode: yes + + - name: "assert that results are as expected" + assert: + that: + - iptables_state is changed + - iptables_state.initial_state != iptables_state.restored + quiet: yes + + rescue: + - name: "assert that results are not as expected for only one reason (xtables lock)" + assert: + that: + - iptables_state is failed + - iptables_state.stderr is search('xtables lock') + quiet: yes + register: xtables_lock + + + +- name: "state=restored changed=true" + block: + - name: "restore state (must report a change, async, no warning)" + iptables_state: + path: "{{ iptables_saved }}" + state: restored + register: iptables_state + async: "{{ ansible_timeout }}" + poll: 0 + + - name: "assert that results are as expected" + assert: + that: + - iptables_state is changed + - iptables_state.initial_state != iptables_state.restored + - iptables_state.applied + quiet: yes + + rescue: + - name: "assert that results are not as expected for only one reason (xtables lock)" + assert: + that: + - iptables_state is failed + - iptables_state.stderr is search('xtables lock') + quiet: yes + register: xtables_lock + + + +- name: "state=restored changed=false" + block: + - name: "restore state (must NOT report a change, async, no warning)" + iptables_state: + path: "{{ iptables_saved }}" + state: restored + register: iptables_state + async: "{{ ansible_timeout }}" + poll: 0 + + - name: "assert that results are as expected" + assert: + that: + - iptables_state is not changed + - iptables_state.initial_state == iptables_state.restored + quiet: yes + + rescue: + - name: "assert that results are not as expected for only one reason (xtables lock)" + assert: + that: + - iptables_state is failed + - iptables_state.stderr is search('xtables lock') + quiet: yes + register: xtables_lock + + + +- name: "state=restored changed=false" + block: + - name: "restore state (check_mode=yes, must NOT report a change, no warning)" + iptables_state: + path: "{{ iptables_saved }}" + state: restored + register: iptables_state + check_mode: yes + + - name: "assert that results are as expected" + assert: + that: + - iptables_state is not changed + - iptables_state.initial_state == iptables_state.restored + quiet: yes + + rescue: + - name: "assert that results are not as expected for only one reason (xtables lock)" + assert: + that: + - iptables_state is failed + - iptables_state.stderr is search('xtables lock') + quiet: yes + register: xtables_lock diff --git a/tests/integration/targets/iptables_state/tasks/tests/01-tables.yml b/tests/integration/targets/iptables_state/tasks/tests/01-tables.yml new file mode 100644 index 0000000000..e09a26a9d9 --- /dev/null +++ b/tests/integration/targets/iptables_state/tasks/tests/01-tables.yml @@ -0,0 +1,299 @@ +--- +- name: "ensure our next rule is not there (iptables)" + iptables: + table: nat + chain: INPUT + jump: ACCEPT + state: absent + +- name: "get state (table filter)" + iptables_state: + table: filter + state: saved + path: "{{ iptables_saved }}" + register: iptables_state + changed_when: false + check_mode: yes + +- name: "assert that results are as expected" + assert: + that: + - "'*filter' in iptables_state.initial_state" + - iptables_state.tables.filter is defined + - iptables_state.tables.nat is undefined + quiet: yes + + + +- name: "get state (table nat)" + iptables_state: + table: nat + state: saved + path: "{{ iptables_saved }}" + register: iptables_state + changed_when: false + check_mode: yes + +- name: "assert that results are as expected" + assert: + that: + - "'*nat' in iptables_state.initial_state" + - "'*filter' in iptables_state.initial_state" + - iptables_state.tables.nat is defined + - iptables_state.tables.filter is undefined + quiet: yes + + + +- name: "save state (table filter)" + iptables_state: + path: "{{ iptables_saved }}" + state: saved + table: filter + register: iptables_state + +- name: "assert that results are as expected" + assert: + that: + - "'*filter' in iptables_state.initial_state" + - "'*filter' in iptables_state.saved" + - "'*nat' in iptables_state.initial_state" + - "'*nat' not in iptables_state.saved" + - iptables_state.tables.filter is defined + - iptables_state.tables.nat is undefined + quiet: yes + + + +- name: "save state (table nat)" + iptables_state: + path: "{{ iptables_saved }}" + state: saved + table: nat + register: iptables_state + +- name: "assert that results are as expected" + assert: + that: + - iptables_state is changed + - "'*nat' in iptables_state.initial_state" + - "'*nat' in iptables_state.saved" + - "'*filter' in iptables_state.initial_state" + - "'*filter' not in iptables_state.saved" + - iptables_state.tables.nat is defined + - iptables_state.tables.filter is undefined + quiet: yes + + + +- name: "save state (any table)" + iptables_state: + path: "{{ iptables_saved }}" + state: saved + register: iptables_state + +- name: "assert that results are as expected" + assert: + that: + - iptables_state is changed + - "'*filter' in iptables_state.initial_state" + - "'*filter' in iptables_state.saved" + - "'*nat' in iptables_state.initial_state" + - "'*nat' in iptables_state.saved" + - iptables_state.tables.filter is defined + - iptables_state.tables.nat is defined + quiet: yes + + + +- name: "restore state (table nat, must NOT report a change, no warning)" + iptables_state: + path: "{{ iptables_saved }}" + state: restored + table: nat + register: iptables_state + async: "{{ ansible_timeout }}" + poll: 0 + +- name: "assert that results are as expected" + assert: + that: + - "'*nat' in iptables_state.initial_state" + - "'*nat' in iptables_state.restored" + - "'*filter' in iptables_state.initial_state" + - "'*filter' not in iptables_state.restored" + - iptables_state.tables.nat is defined + - iptables_state.tables.filter is undefined + - iptables_state is not changed + quiet: yes + + + +- name: "change NAT table (iptables)" + iptables: + table: nat + chain: INPUT + jump: ACCEPT + state: present + + + +- name: "restore state (table nat, must report a change, no warning)" + iptables_state: + path: "{{ iptables_saved }}" + state: restored + table: nat + register: iptables_state + async: "{{ ansible_timeout }}" + poll: 0 + +- name: "assert that results are as expected" + assert: + that: + - "'*nat' in iptables_state.initial_state" + - "'*nat' in iptables_state.restored" + - "'*filter' in iptables_state.initial_state" + - "'*filter' not in iptables_state.restored" + - iptables_state.tables.nat is defined + - "'-A INPUT -j ACCEPT' in iptables_state.tables.nat" + - "'-A INPUT -j ACCEPT' not in iptables_state.restored" + - iptables_state.tables.filter is undefined + - iptables_state is changed + quiet: yes + + + +- name: "get security, raw and mangle tables states" + iptables_state: + path: "{{ iptables_saved }}" + state: saved + table: "{{ item }}" + loop: + - security + - raw + - mangle + changed_when: false + check_mode: yes + + + +- name: "save state (any table)" + iptables_state: + path: "{{ iptables_saved }}" + state: saved + register: iptables_state + +- name: "assert that results are as expected" + assert: + that: + - "'filter' in iptables_state.tables" + - "'*filter' in iptables_state.saved" + - "'mangle' in iptables_state.tables" + - "'*mangle' in iptables_state.saved" + - "'nat' in iptables_state.tables" + - "'*nat' in iptables_state.saved" + - "'raw' in iptables_state.tables" + - "'*raw' in iptables_state.saved" + - "'security' in iptables_state.tables" + - "'*security' in iptables_state.saved" + quiet: yes + + + +- name: "save filter table into a test file" + iptables_state: + path: "{{ iptables_tests }}" + table: filter + state: saved + +- name: "add a table header in comments (# *mangle)" + lineinfile: + path: "{{ iptables_tests }}" + line: "# *mangle" + + + +- name: "restore state (table filter, must NOT report a change, no warning)" + iptables_state: + path: "{{ iptables_tests }}" + table: filter + state: restored + register: iptables_state + async: "{{ ansible_timeout }}" + poll: 0 + +- name: "assert that results are as expected" + assert: + that: + - "'*filter' in iptables_state.initial_state" + - "'*mangle' in iptables_state.initial_state" + - "'*nat' in iptables_state.initial_state" + - "'*raw' in iptables_state.initial_state" + - "'*security' in iptables_state.initial_state" + - "'filter' in iptables_state.tables" + - "'mangle' not in iptables_state.tables" + - "'nat' not in iptables_state.tables" + - "'raw' not in iptables_state.tables" + - "'security' not in iptables_state.tables" + - "'*filter' in iptables_state.restored" + - "'*mangle' not in iptables_state.restored" + - "'*nat' not in iptables_state.restored" + - "'*raw' not in iptables_state.restored" + - "'*security' not in iptables_state.restored" + - iptables_state is not changed + quiet: yes + + + +- name: "restore state (any table, must NOT report a change, no warning)" + iptables_state: + path: "{{ iptables_tests }}" + state: restored + register: iptables_state + async: "{{ ansible_timeout }}" + poll: 0 + +- name: "assert that results are as expected" + assert: + that: + - "'*filter' in iptables_state.initial_state" + - "'*mangle' in iptables_state.initial_state" + - "'*nat' in iptables_state.initial_state" + - "'*raw' in iptables_state.initial_state" + - "'*security' in iptables_state.initial_state" + - "'filter' in iptables_state.tables" + - "'mangle' in iptables_state.tables" + - "'nat' in iptables_state.tables" + - "'raw' in iptables_state.tables" + - "'security' in iptables_state.tables" + - "'*filter' in iptables_state.restored" + - "'*mangle' in iptables_state.restored" + - "'*nat' in iptables_state.restored" + - "'*raw' in iptables_state.restored" + - "'*security' in iptables_state.restored" + - iptables_state is not changed + quiet: yes + + + +- name: "restore state (table mangle, must fail, no warning)" + iptables_state: + path: "{{ iptables_tests }}" + table: mangle + state: restored + register: iptables_state + async: "{{ ansible_timeout }}" + poll: 0 + ignore_errors: yes + +- name: "explain expected failure" + assert: + that: + - iptables_state is failed + - "iptables_state.msg == 'Table mangle to restore not defined in {{ iptables_tests }}'" + success_msg: >- + The previous error has been triggered by trying to restore a table + that is missing in the file provided to iptables-restore. + fail_msg: >- + The previous task should have failed due to a missing table (mangle) + in the file to restore iptables state from. diff --git a/tests/integration/targets/iptables_state/tasks/tests/10-rollback.yml b/tests/integration/targets/iptables_state/tasks/tests/10-rollback.yml new file mode 100644 index 0000000000..1a9db2900b --- /dev/null +++ b/tests/integration/targets/iptables_state/tasks/tests/10-rollback.yml @@ -0,0 +1,199 @@ +--- +- name: "create a blocking ruleset with a DROP policy" + copy: + dest: "{{ iptables_tests }}" + content: | + *filter + :INPUT DROP + COMMIT + + + +- name: "restore state from the test file (check_mode, must report a change)" + iptables_state: + path: "{{ iptables_tests }}" + state: restored + register: iptables_state + check_mode: yes + +- name: "assert that results are as expected" + assert: + that: + - iptables_state is changed + + + +- name: "fail to restore state from the test file" + block: + - name: "restore state from the test file (bad policies, expected error -> rollback)" + iptables_state: + path: "{{ iptables_tests }}" + state: restored + register: iptables_state + async: "{{ ansible_timeout }}" + poll: 0 + + rescue: + - name: "explain expected failure" + assert: + that: + - iptables_state is not changed + - not iptables_state.applied + success_msg: >- + The previous error has been triggered to test the rollback. If you + are there, it means that 1) connection has been lost right after the + bad rules have been restored; 2) a rollback happened, so the bad + rules are not applied, finally; 3) module failed because it didn't + reach the wanted state, but at least host is not lost !!! + fail_msg: >- + The previous error has been triggered but its results are not as + expected. + +- name: "check that the expected failure happened" + assert: + that: + - iptables_state is failed + + + +- name: "fail to restore state from the test file (again)" + block: + - name: "try again, with a higher timeout (bad policies, same expected error)" + iptables_state: + path: "{{ iptables_tests }}" + state: restored + register: iptables_state + async: "{{ ansible_timeout }}" + poll: 0 + vars: + ansible_timeout: "{{ max_delay | d(300) }}" + + rescue: + - name: "explain expected failure" + assert: + that: + - iptables_state is not changed + - not iptables_state.applied + success_msg: >- + The previous error has been triggered to test the rollback. If you + are there, it means that 1) connection has been lost right after the + bad rules have been restored; 2) a rollback happened, so the bad + rules are not applied, finally; 3) module failed because it didn't + reach the wanted state, but at least host is not lost !!! + fail_msg: >- + The previous error has been triggered but its results are not as + expected. + +- name: "check that the expected failure happened" + assert: + that: + - iptables_state is failed + + + +- name: "restore state from backup (must NOT report a change)" + iptables_state: + path: "{{ iptables_saved }}" + state: restored + register: iptables_state + async: "{{ ansible_timeout }}" + poll: 0 + +- name: "assert that results are as expected" + assert: + that: + - iptables_state is not changed + + + +- name: "restore state from backup (mangle, must NOT report a change)" + iptables_state: + path: "{{ iptables_saved }}" + table: mangle + state: restored + register: iptables_state + async: "{{ ansible_timeout }}" + poll: 0 + +- name: "assert that results are as expected" + assert: + that: + - iptables_state is not changed + + + +- name: "create a blocking ruleset with a REJECT rule" + copy: + dest: "{{ iptables_tests }}" + content: | + *filter + -A INPUT -j REJECT + COMMIT + + + +- name: "fail to restore state from the test file (again)" + block: + - name: "restore state from the test file (bad rules, expected error -> rollback)" + iptables_state: + path: "{{ iptables_tests }}" + state: restored + register: iptables_state + async: "{{ ansible_timeout }}" + poll: 0 + + rescue: + - name: "explain expected failure" + assert: + that: + - iptables_state is not changed + - not iptables_state.applied + success_msg: >- + The previous error has been triggered to test the rollback. If you + are there, it means that 1) connection has been lost right after the + bad rules have been restored; 2) a rollback happened, so the bad + rules are not applied, finally; 3) module failed because it didn't + reach the wanted state, but at least host is not lost !!! + fail_msg: >- + The previous error has been triggered but its results are not as + expected. + +- name: "check that the expected failure happened" + assert: + that: + - iptables_state is failed + + + +- name: "fail to restore state from the test file (again)" + block: + - name: "try again, with a higher timeout (bad rules, same expected error)" + iptables_state: + path: "{{ iptables_tests }}" + state: restored + register: iptables_state + async: "{{ ansible_timeout }}" + poll: 0 + vars: + ansible_timeout: "{{ max_delay | d(300) }}" + + rescue: + - name: "explain expected failure" + assert: + that: + - iptables_state is not changed + - not iptables_state.applied + success_msg: >- + The previous error has been triggered to test the rollback. If you + are there, it means that 1) connection has been lost right after the + bad rules have been restored; 2) a rollback happened, so the bad + rules are not applied, finally; 3) module failed because it didn't + reach the wanted state, but at least host is not lost !!! + fail_msg: >- + The previous error has been triggered but its results are not as + expected. + +- name: "check that the expected failure happened" + assert: + that: + - iptables_state is failed diff --git a/tests/sanity/ignore-2.10.txt b/tests/sanity/ignore-2.10.txt index 1b15df1cc6..5bcacd2929 100644 --- a/tests/sanity/ignore-2.10.txt +++ b/tests/sanity/ignore-2.10.txt @@ -1224,6 +1224,7 @@ plugins/modules/system/gconftool2.py validate-modules:parameter-state-invalid-ch plugins/modules/system/gconftool2.py validate-modules:parameter-type-not-in-doc plugins/modules/system/interfaces_file.py pylint:blacklisted-name plugins/modules/system/interfaces_file.py validate-modules:parameter-type-not-in-doc +plugins/modules/system/iptables_state.py validate-modules:undocumented-parameter plugins/modules/system/java_cert.py pylint:blacklisted-name plugins/modules/system/java_keystore.py validate-modules:doc-missing-type plugins/modules/system/java_keystore.py validate-modules:parameter-type-not-in-doc diff --git a/tests/sanity/ignore-2.11.txt b/tests/sanity/ignore-2.11.txt index 1b15df1cc6..5bcacd2929 100644 --- a/tests/sanity/ignore-2.11.txt +++ b/tests/sanity/ignore-2.11.txt @@ -1224,6 +1224,7 @@ plugins/modules/system/gconftool2.py validate-modules:parameter-state-invalid-ch plugins/modules/system/gconftool2.py validate-modules:parameter-type-not-in-doc plugins/modules/system/interfaces_file.py pylint:blacklisted-name plugins/modules/system/interfaces_file.py validate-modules:parameter-type-not-in-doc +plugins/modules/system/iptables_state.py validate-modules:undocumented-parameter plugins/modules/system/java_cert.py pylint:blacklisted-name plugins/modules/system/java_keystore.py validate-modules:doc-missing-type plugins/modules/system/java_keystore.py validate-modules:parameter-type-not-in-doc diff --git a/tests/sanity/ignore-2.9.txt b/tests/sanity/ignore-2.9.txt index 635000371a..de07af1b24 100644 --- a/tests/sanity/ignore-2.9.txt +++ b/tests/sanity/ignore-2.9.txt @@ -947,6 +947,7 @@ plugins/modules/system/gconftool2.py pylint:blacklisted-name plugins/modules/system/gconftool2.py validate-modules:parameter-type-not-in-doc plugins/modules/system/interfaces_file.py pylint:blacklisted-name plugins/modules/system/interfaces_file.py validate-modules:parameter-type-not-in-doc +plugins/modules/system/iptables_state.py validate-modules:undocumented-parameter plugins/modules/system/java_cert.py pylint:blacklisted-name plugins/modules/system/java_keystore.py validate-modules:doc-missing-type plugins/modules/system/kernel_blacklist.py validate-modules:parameter-type-not-in-doc