xoxys.general/plugins/modules/iptables_raw.py

1082 lines
42 KiB
Python
Raw Normal View History

2020-08-18 21:44:49 +00:00
# -*- coding: utf-8 -*-
2020-08-22 12:49:44 +00:00
"""
IPtables raw module.
(c) 2016, Strahinja Kustudic <strahinjak@nordeus.com>
(c) 2016, Damir Markovic <damir@damirda.com>
This file is part of Ansible
Ansible is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Ansible is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Ansible. If not, see <http://www.gnu.org/licenses/>.
"""
2020-08-18 21:44:49 +00:00
2020-08-22 12:49:44 +00:00
ANSIBLE_METADATA = {'status': ['preview'], 'supported_by': 'community', 'metadata_version': '1.0'}
2020-08-18 21:44:49 +00:00
2020-08-22 12:49:44 +00:00
DOCUMENTATION = r'''
2020-08-18 21:44:49 +00:00
---
module: iptables_raw
short_description: Manage iptables rules
version_added: "2.4"
description:
- Add/remove iptables rules while keeping state.
options:
backup:
description:
- Create a backup of the iptables state file before overwriting it.
required: false
choices: ["yes", "no"]
default: "no"
ipversion:
description:
- Target the IP version this rule is for.
required: false
default: "4"
choices: ["4", "6"]
keep_unmanaged:
description:
- If set to C(yes) keeps active iptables (unmanaged) rules for the target
C(table) and gives them C(weight=90). This means these rules will be
ordered after most of the rules, since default priority is 40, so they
2020-08-22 12:49:44 +00:00
shouldn't be able to block any allow rules. If set to C(no) deletes all
2020-08-18 21:44:49 +00:00
rules which are not set by this module.
- "WARNING: Be very careful when running C(keep_unmanaged=no) for the
2020-08-22 12:49:44 +00:00
first time, since if you don't specify correct rules, you can block
2020-08-18 21:44:49 +00:00
yourself out of the managed host."
required: false
choices: ["yes", "no"]
default: "yes"
name:
description:
- Name that will be used as an identifier for these rules. It can contain
alphanumeric characters, underscore, hyphen, or a space; has to be
UNIQUE for a specified C(table). You can also pass C(name=*) with
C(state=absent) to flush all rules in the selected table, or even all
tables with C(table=*).
required: true
rules:
description:
- The rules that we want to add. Accepts multiline values.
- "Note: You can only use C(-A)/C(--append), C(-N)/C(--new-chain), and
C(-P)/C(--policy) to specify rules."
required: false
state:
description:
- The state this rules fragment should be in.
choices: ["present", "absent"]
required: false
default: present
table:
description:
- The table this rule applies to. You can specify C(table=*) only with
with C(name=*) and C(state=absent) to flush all rules in all tables.
choices: ["filter", "nat", "mangle", "raw", "security", "*"]
required: false
default: filter
weight:
description:
- Determines the order of the rules. Lower C(weight) means higher
priority. Supported range is C(0 - 99)
choices: ["0 - 99"]
required: false
default: 40
notes:
- Requires C(iptables) package. Debian-based distributions additionally
require C(iptables-persistent).
- "Depending on the distribution, iptables rules are saved in different
locations, so that they can be loaded on boot. Red Hat distributions (RHEL,
CentOS, etc): C(/etc/sysconfig/iptables) and C(/etc/sysconfig/ip6tables);
Debian distributions (Debian, Ubuntu, etc): C(/etc/iptables/rules.v4) and
C(/etc/iptables/rules.v6); other distributions: C(/etc/sysconfig/iptables)
and C(/etc/sysconfig/ip6tables)."
2020-08-22 12:49:44 +00:00
- This module saves state in C(/etc/ansible-iptables) directory, so don't
2020-08-18 21:44:49 +00:00
modify this directory!
author:
- "Strahinja Kustudic (@kustodian)"
- "Damir Markovic (@damirda)"
2020-08-22 12:49:44 +00:00
'''
2020-08-18 21:44:49 +00:00
2020-08-22 12:49:44 +00:00
EXAMPLES = '''
2020-08-18 21:44:49 +00:00
# Allow all IPv4 traffic coming in on port 80 (http)
- iptables_raw:
name: allow_tcp_80
2020-08-22 12:49:44 +00:00
rules: '-A INPUT -p tcp -m tcp --dport 80 -j ACCEPT'
2020-08-18 21:44:49 +00:00
# Set default rules with weight 10 and disregard all unmanaged rules
- iptables_raw:
name: default_rules
weight: 10
keep_unmanaged: no
rules: |
-A INPUT -m state --state RELATED,ESTABLISHED -j ACCEPT
-A INPUT -i lo -j ACCEPT
-A INPUT -p tcp -m state --state NEW -m tcp --dport 22 -j ACCEPT
-P INPUT DROP
-P FORWARD DROP
-P OUTPUT ACCEPT
# Allow all IPv6 traffic coming in on port 443 (https) with weight 50
- iptables_raw:
ipversion: 6
weight: 50
name: allow_tcp_443
2020-08-22 12:49:44 +00:00
rules: '-A INPUT -p tcp -m tcp --dport 443 -j ACCEPT'
2020-08-18 21:44:49 +00:00
# Remove the above rule
- iptables_raw:
state: absent
ipversion: 6
name: allow_tcp_443
# Define rules with a custom chain
- iptables_raw:
name: custom1_rules
rules: |
-N CUSTOM1
-A CUSTOM1 -s 192.168.0.0/24 -j ACCEPT
# Reset all IPv4 iptables rules in all tables and allow all traffic
- iptables_raw:
2020-08-22 12:49:44 +00:00
name: '*'
table: '*'
2020-08-18 21:44:49 +00:00
state: absent
2020-08-22 12:49:44 +00:00
'''
2020-08-18 21:44:49 +00:00
2020-08-22 12:49:44 +00:00
RETURN = '''
2020-08-18 21:44:49 +00:00
state:
description: state of the rules
returned: success
type: string
sample: present
name:
description: name of the rules
returned: success
type: string
sample: open_tcp_80
weight:
description: weight of the rules
returned: success
type: int
sample: 40
ipversion:
description: IP version of iptables used
returned: success
type: int
sample: 6
rules:
description: passed rules
returned: success
type: string
sample: "-A INPUT -p tcp -m tcp --dport 80 -j ACCEPT"
table:
description: iptables table used
returned: success
type: string
sample: filter
backup:
description: if the iptables file should backed up
returned: success
type: boolean
sample: False
keep_unmanaged:
description: if it should keep unmanaged rules
returned: success
type: boolean
sample: True
2020-08-22 12:49:44 +00:00
'''
2020-08-18 21:44:49 +00:00
import fcntl
import os
import re
import shlex
import tempfile
import time
from collections import defaultdict
from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils.basic import json
# Genereates a diff dictionary from an old and new table dump.
def generate_diff(dump_old, dump_new):
diff = dict()
if dump_old != dump_new:
2020-08-22 12:49:44 +00:00
diff['before'] = dump_old
diff['after'] = dump_new
2020-08-18 21:44:49 +00:00
return diff
def compare_dictionaries(dict1, dict2):
if dict1 is None or dict2 is None:
return False
if not (isinstance(dict1, dict) and isinstance(dict2, dict)):
return False
shared_keys = set(dict2.keys()) & set(dict2.keys())
if not (len(shared_keys) == len(dict1.keys()) and len(shared_keys) == len(dict2.keys())):
return False
dicts_are_equal = True
for key in dict1.keys():
if isinstance(dict1[key], dict):
dicts_are_equal = dicts_are_equal and compare_dictionaries(dict1[key], dict2[key])
else:
dicts_are_equal = dicts_are_equal and (dict1[key] == dict2[key])
if not dicts_are_equal:
break
return dicts_are_equal
class Iptables:
# Default chains for each table
DEFAULT_CHAINS = {
2020-08-22 12:49:44 +00:00
'filter': ['INPUT', 'FORWARD', 'OUTPUT'],
'raw': ['PREROUTING', 'OUTPUT'],
'nat': ['PREROUTING', 'INPUT', 'OUTPUT', 'POSTROUTING'],
'mangle': ['PREROUTING', 'INPUT', 'FORWARD', 'OUTPUT', 'POSTROUTING'],
'security': ['INPUT', 'FORWARD', 'OUTPUT']
2020-08-18 21:44:49 +00:00
}
# List of tables
TABLES = list(DEFAULT_CHAINS.copy().keys())
# Directory which will store the state file.
2020-08-22 12:49:44 +00:00
STATE_DIR = '/etc/ansible-iptables'
2020-08-18 21:44:49 +00:00
# Key used for unmanaged rules
2020-08-22 12:49:44 +00:00
UNMANAGED_RULES_KEY_NAME = '$unmanaged_rules$'
2020-08-18 21:44:49 +00:00
# Only allow alphanumeric characters, underscore, hyphen, or a space for
2020-08-22 12:49:44 +00:00
# now. We don't want to have problems while parsing comments using regular
2020-08-18 21:44:49 +00:00
# expressions.
2020-08-22 12:49:44 +00:00
RULE_NAME_ALLOWED_CHARS = 'a-zA-Z0-9_ -'
2020-08-18 21:44:49 +00:00
module = None
def __init__(self, module, ipversion):
# Create directory for json files.
if not os.path.exists(self.STATE_DIR):
os.makedirs(self.STATE_DIR)
if Iptables.module is None:
Iptables.module = module
self.state_save_path = self._get_state_save_path(ipversion)
self.system_save_path = self._get_system_save_path(ipversion)
self.state_dict = self._read_state_file()
self.bins = self._get_bins(ipversion)
self.iptables_names_file = self._get_iptables_names_file(ipversion)
# Check if we have a required iptables version.
self._check_compatibility()
2020-08-22 12:49:44 +00:00
# Save active iptables rules for all tables, so that we don't
# need to fetch them every time using 'iptables-save' command.
2020-08-18 21:44:49 +00:00
self._active_rules = {}
2020-08-22 12:49:44 +00:00
self._refresh_active_rules(table='*')
2020-08-18 21:44:49 +00:00
def __eq__(self, other):
return (
isinstance(other, self.__class__)
and compare_dictionaries(other.state_dict, self.state_dict)
)
def __ne__(self, other):
return not self.__eq__(other)
def _get_bins(self, ipversion):
2020-08-22 12:49:44 +00:00
if ipversion == '4':
2020-08-18 21:44:49 +00:00
return {
2020-08-22 12:49:44 +00:00
'iptables': Iptables.module.get_bin_path('iptables'),
'iptables-save': Iptables.module.get_bin_path('iptables-save'),
'iptables-restore': Iptables.module.get_bin_path('iptables-restore')
2020-08-18 21:44:49 +00:00
}
else:
return {
2020-08-22 12:49:44 +00:00
'iptables': Iptables.module.get_bin_path('ip6tables'),
'iptables-save': Iptables.module.get_bin_path('ip6tables-save'),
'iptables-restore': Iptables.module.get_bin_path('ip6tables-restore')
2020-08-18 21:44:49 +00:00
}
def _get_iptables_names_file(self, ipversion):
2020-08-22 12:49:44 +00:00
if ipversion == '4':
return '/proc/net/ip_tables_names'
2020-08-18 21:44:49 +00:00
else:
2020-08-22 12:49:44 +00:00
return '/proc/net/ip6_tables_names'
2020-08-18 21:44:49 +00:00
# Return a list of active iptables tables
def _get_list_of_active_tables(self):
if os.path.isfile(self.iptables_names_file):
2020-08-22 12:49:44 +00:00
table_names = open(self.iptables_names_file, 'r').read()
2020-08-18 21:44:49 +00:00
return table_names.splitlines()
else:
return []
# If /etc/debian_version exist, this means this is a debian based OS (Ubuntu, Mint, etc...)
def _is_debian(self):
2020-08-22 12:49:44 +00:00
return os.path.isfile('/etc/debian_version')
2020-08-18 21:44:49 +00:00
# Get the iptables system save path.
2020-08-22 12:49:44 +00:00
# Supports RHEL/CentOS '/etc/sysconfig/' location.
# Supports Debian/Ubuntu/Mint, '/etc/iptables/' location.
2020-08-18 21:44:49 +00:00
def _get_system_save_path(self, ipversion):
# distro detection, path setting should be added
2020-08-22 12:49:44 +00:00
if ipversion == '4':
2020-08-18 21:44:49 +00:00
if self._is_debian():
2020-08-22 12:49:44 +00:00
return '/etc/iptables/rules.v4'
2020-08-18 21:44:49 +00:00
else:
2020-08-22 12:49:44 +00:00
return '/etc/sysconfig/iptables'
2020-08-18 21:44:49 +00:00
else:
if self._is_debian():
2020-08-22 12:49:44 +00:00
return '/etc/iptables/rules.v6'
2020-08-18 21:44:49 +00:00
else:
2020-08-22 12:49:44 +00:00
return '/etc/sysconfig/ip6tables'
2020-08-18 21:44:49 +00:00
# Return path to json state file.
def _get_state_save_path(self, ipversion):
2020-08-22 12:49:44 +00:00
if ipversion == '4':
return self.STATE_DIR + '/iptables.json'
2020-08-18 21:44:49 +00:00
else:
2020-08-22 12:49:44 +00:00
return self.STATE_DIR + '/ip6tables.json'
2020-08-18 21:44:49 +00:00
# Checks if iptables is installed and if we have a correct version.
def _check_compatibility(self):
from distutils.version import StrictVersion
2020-08-22 12:49:44 +00:00
cmd = [self.bins['iptables'], '--version']
2020-08-18 21:44:49 +00:00
rc, stdout, stderr = Iptables.module.run_command(cmd, check_rc=False)
if rc == 0:
2020-08-22 12:49:44 +00:00
result = re.search(r'^ip6tables\s+v(\d+\.\d+)\.\d+$', stdout)
2020-08-18 21:44:49 +00:00
if result:
version = result.group(1)
2020-08-22 12:49:44 +00:00
# CentOS 5 ip6tables (v1.3.x) doesn't support comments,
2020-08-18 21:44:49 +00:00
# which means it cannot be used with this module.
2020-08-22 12:49:44 +00:00
if StrictVersion(version) < StrictVersion('1.4'):
2020-08-18 21:44:49 +00:00
Iptables.module.fail_json(
msg="This module isn't compatible with ip6tables versions older than 1.4.x"
)
else:
Iptables.module.fail_json(
2020-08-22 12:49:44 +00:00
msg='Could not fetch iptables version! Is iptables installed?'
2020-08-18 21:44:49 +00:00
)
# Read rules from the json state file and return a dict.
def _read_state_file(self):
2020-08-22 12:49:44 +00:00
json_str = '{}'
2020-08-18 21:44:49 +00:00
if os.path.isfile(self.state_save_path):
2020-08-22 12:49:44 +00:00
json_str = open(self.state_save_path, 'r').read()
read_dict = defaultdict(lambda: dict(dump='', rules_dict={}), json.loads(json_str))
2020-08-18 21:44:49 +00:00
return read_dict
# Checks if a table exists in the state_dict.
def _has_table(self, tbl):
return tbl in self.state_dict
# Deletes table from the state_dict.
def _delete_table(self, tbl):
if self._has_table(tbl):
del self.state_dict[tbl]
# Acquires lock or exits after wait_for_seconds if it cannot be acquired.
def acquire_lock_or_exit(self, wait_for_seconds=10):
2020-08-22 12:49:44 +00:00
lock_file = self.STATE_DIR + '/.iptables.lock'
2020-08-18 21:44:49 +00:00
i = 0
2020-08-22 12:49:44 +00:00
f = open(lock_file, 'w+')
2020-08-18 21:44:49 +00:00
while i < wait_for_seconds:
try:
fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)
return
except IOError:
i += 1
time.sleep(1)
Iptables.module.fail_json(
2020-08-22 12:49:44 +00:00
msg='Could not acquire lock to continue execution! '
'Probably another instance of this module is running.'
2020-08-18 21:44:49 +00:00
)
2020-08-22 12:49:44 +00:00
# Check if a table has anything to flush (to check all tables pass table='*').
2020-08-18 21:44:49 +00:00
def table_needs_flush(self, table):
needs_flush = False
2020-08-22 12:49:44 +00:00
if table == '*':
2020-08-18 21:44:49 +00:00
for tbl in Iptables.TABLES:
# If the table exists or if it needs to be flushed that means will make changes.
if self._has_table(tbl) or self._single_table_needs_flush(tbl):
needs_flush = True
break
# Only flush the specified table
else:
if self._has_table(table) or self._single_table_needs_flush(table):
needs_flush = True
return needs_flush
# Check if a passed table needs to be flushed.
def _single_table_needs_flush(self, table):
needs_flush = False
active_rules = self._get_active_rules(table)
if active_rules:
policies = self._filter_default_chain_policies(active_rules, table)
chains = self._filter_custom_chains(active_rules, table)
rules = self._filter_rules(active_rules, table)
# Go over default policies and check if they are all ACCEPT.
for line in policies.splitlines():
2020-08-22 12:49:44 +00:00
if not re.search(r'\bACCEPT\b', line):
2020-08-18 21:44:49 +00:00
needs_flush = True
break
# If there is at least one rule or custom chain, that means we need flush.
if len(chains) > 0 or len(rules) > 0:
needs_flush = True
return needs_flush
# Returns a copy of the rules dict of a passed table.
def _get_table_rules_dict(self, table):
2020-08-22 12:49:44 +00:00
return self.state_dict[table]['rules_dict'].copy()
2020-08-18 21:44:49 +00:00
# Returns saved table dump.
def get_saved_table_dump(self, table):
2020-08-22 12:49:44 +00:00
return self.state_dict[table]['dump']
2020-08-18 21:44:49 +00:00
# Sets saved table dump.
def _set_saved_table_dump(self, table, dump):
2020-08-22 12:49:44 +00:00
self.state_dict[table]['dump'] = dump
2020-08-18 21:44:49 +00:00
# Updates saved table dump from the active rules.
def refresh_saved_table_dump(self, table):
active_rules = self._get_active_rules(table)
self._set_saved_table_dump(table, active_rules)
# Sets active rules of the passed table.
def _set_active_rules(self, table, rules):
self._active_rules[table] = rules
# Return active rules of the passed table.
def _get_active_rules(self, table, clean=True):
2020-08-22 12:49:44 +00:00
active_rules = ''
if table == '*':
2020-08-18 21:44:49 +00:00
all_rules = []
for tbl in Iptables.TABLES:
if tbl in self._active_rules:
all_rules.append(self._active_rules[tbl])
2020-08-22 12:49:44 +00:00
active_rules = '\n'.join(all_rules)
2020-08-18 21:44:49 +00:00
else:
active_rules = self._active_rules[table]
if clean:
return self._clean_save_dump(active_rules)
else:
return active_rules
2020-08-22 12:49:44 +00:00
# Refresh active rules of a table ('*' for all tables).
2020-08-18 21:44:49 +00:00
def _refresh_active_rules(self, table):
2020-08-22 12:49:44 +00:00
if table == '*':
2020-08-18 21:44:49 +00:00
for tbl in Iptables.TABLES:
self._set_active_rules(tbl, self._get_system_active_rules(tbl))
else:
self._set_active_rules(table, self._get_system_active_rules(table))
2020-08-22 12:49:44 +00:00
# Get iptables-save dump of active rules of one or all tables (pass '*')
2020-08-18 21:44:49 +00:00
# and return it as a string.
def _get_system_active_rules(self, table):
active_tables = self._get_list_of_active_tables()
2020-08-22 12:49:44 +00:00
if table == '*':
cmd = [self.bins['iptables-save']]
2020-08-18 21:44:49 +00:00
# If there are no active tables, that means there are no rules
if not active_tables:
2020-08-22 12:49:44 +00:00
return ''
2020-08-18 21:44:49 +00:00
else:
2020-08-22 12:49:44 +00:00
cmd = [self.bins['iptables-save'], '-t', table]
2020-08-18 21:44:49 +00:00
# If the table is not active, that means it has no rules
if table not in active_tables:
2020-08-22 12:49:44 +00:00
return ''
2020-08-18 21:44:49 +00:00
rc, stdout, stderr = Iptables.module.run_command(cmd, check_rc=True)
return stdout
# Splits a rule into tokens
def _split_rule_into_tokens(self, rule):
try:
return shlex.split(rule, comments=True)
except Exception:
2020-08-22 12:49:44 +00:00
msg = 'Could not parse the iptables rule:\n%s' % rule
2020-08-18 21:44:49 +00:00
Iptables.module.fail_json(msg=msg)
# Removes comment lines and empty lines from rules.
@staticmethod
def clean_up_rules(rules):
cleaned_rules = []
for line in rules.splitlines():
# Remove lines with comments and empty lines.
if not (Iptables.is_comment(line) or Iptables.is_empty_line(line)):
cleaned_rules.append(line)
2020-08-22 12:49:44 +00:00
return '\n'.join(cleaned_rules)
2020-08-18 21:44:49 +00:00
# Checks if the line is a custom chain in specific iptables table.
@staticmethod
def is_custom_chain(line, table):
default_chains = Iptables.DEFAULT_CHAINS[table]
2020-08-22 12:49:44 +00:00
if re.match(r'\s*(:|(-N|--new-chain)\s+)[^\s]+', line) and not re.match(
r'\s*(:|(-N|--new-chain)\s+)\b(' + '|'.join(default_chains) + r')\b', line
2020-08-18 21:44:49 +00:00
):
return True
else:
return False
# Checks if the line is a default chain of an iptables table.
@staticmethod
def is_default_chain(line, table):
default_chains = Iptables.DEFAULT_CHAINS[table]
if re.match(
2020-08-22 12:49:44 +00:00
r'\s*(:|(-P|--policy)\s+)\b(' + '|'.join(default_chains) + r')\b\s+(ACCEPT|DROP)', line
2020-08-18 21:44:49 +00:00
):
return True
else:
return False
# Checks if a line is an iptables rule.
@staticmethod
def is_rule(line):
2020-08-22 12:49:44 +00:00
# We should only allow adding rules with '-A/--append', since others don't make any sense.
if re.match(r'\s*(-A|--append)\s+[^\s]+', line):
2020-08-18 21:44:49 +00:00
return True
else:
return False
2020-08-22 12:49:44 +00:00
# Checks if a line starts with '#'.
2020-08-18 21:44:49 +00:00
@staticmethod
def is_comment(line):
2020-08-22 12:49:44 +00:00
if re.match(r'\s*#', line):
2020-08-18 21:44:49 +00:00
return True
else:
return False
# Checks if a line is empty.
@staticmethod
def is_empty_line(line):
2020-08-22 12:49:44 +00:00
if re.match(r'^$', line.strip()):
2020-08-18 21:44:49 +00:00
return True
else:
return False
# Return name of custom chain from the rule.
def _get_custom_chain_name(self, line, table):
if Iptables.is_custom_chain(line, table):
2020-08-22 12:49:44 +00:00
return re.match(r'\s*(:|(-N|--new-chain)\s+)([^\s]+)', line).group(3)
2020-08-18 21:44:49 +00:00
else:
2020-08-22 12:49:44 +00:00
return ''
2020-08-18 21:44:49 +00:00
# Return name of default chain from the rule.
def _get_default_chain_name(self, line, table):
if Iptables.is_default_chain(line, table):
2020-08-22 12:49:44 +00:00
return re.match(r'\s*(:|(-N|--new-chain)\s+)([^\s]+)', line).group(3)
2020-08-18 21:44:49 +00:00
else:
2020-08-22 12:49:44 +00:00
return ''
2020-08-18 21:44:49 +00:00
# Return target of the default chain from the rule.
def _get_default_chain_target(self, line, table):
if Iptables.is_default_chain(line, table):
2020-08-22 12:49:44 +00:00
return re.match(r'\s*(:|(-N|--new-chain)\s+)([^\s]+)\s+([A-Z]+)', line).group(4)
2020-08-18 21:44:49 +00:00
else:
2020-08-22 12:49:44 +00:00
return ''
2020-08-18 21:44:49 +00:00
# Removes duplicate custom chains from the table rules.
def _remove_duplicate_custom_chains(self, rules, table):
all_rules = []
custom_chain_names = []
for line in rules.splitlines():
# Extract custom chains.
if Iptables.is_custom_chain(line, table):
chain_name = self._get_custom_chain_name(line, table)
if chain_name not in custom_chain_names:
custom_chain_names.append(chain_name)
all_rules.append(line)
else:
all_rules.append(line)
2020-08-22 12:49:44 +00:00
return '\n'.join(all_rules)
2020-08-18 21:44:49 +00:00
# Returns current iptables-save dump cleaned from comments and packet/byte counters.
def _clean_save_dump(self, simple_rules):
cleaned_dump = []
for line in simple_rules.splitlines():
# Ignore comments.
if Iptables.is_comment(line):
continue
2020-08-22 12:49:44 +00:00
# Reset counters for chains (begin with ':'), for easier comparing later on.
if re.match(r'\s*:', line):
cleaned_dump.append(re.sub(r'\[([0-9]+):([0-9]+)\]', '[0:0]', line))
2020-08-18 21:44:49 +00:00
else:
cleaned_dump.append(line)
2020-08-22 12:49:44 +00:00
cleaned_dump.append('\n')
return '\n'.join(cleaned_dump)
2020-08-18 21:44:49 +00:00
# Returns lines with default chain policies.
def _filter_default_chain_policies(self, rules, table):
chains = []
for line in rules.splitlines():
if Iptables.is_default_chain(line, table):
chains.append(line)
2020-08-22 12:49:44 +00:00
return '\n'.join(chains)
2020-08-18 21:44:49 +00:00
# Returns lines with iptables rules from an iptables-save table dump
# (removes chain policies, custom chains, comments and everything else). By
2020-08-22 12:49:44 +00:00
# default returns all rules, if 'only_unmanged=True' returns rules which
2020-08-18 21:44:49 +00:00
# are not managed by Ansible.
def _filter_rules(self, rules, table, only_unmanaged=False):
filtered_rules = []
for line in rules.splitlines():
if Iptables.is_rule(line):
if only_unmanaged:
tokens = self._split_rule_into_tokens(line)
2020-08-22 12:49:44 +00:00
# We need to check if a rule has a comment which starts with 'ansible[name]'
if '--comment' in tokens:
comment_index = tokens.index('--comment') + 1
2020-08-18 21:44:49 +00:00
if comment_index < len(tokens):
# Fetch the comment
comment = tokens[comment_index]
2020-08-22 12:49:44 +00:00
# Skip the rule if the comment starts with 'ansible[name]'
2020-08-18 21:44:49 +00:00
if not re.match(
2020-08-22 12:49:44 +00:00
r'ansible\[[' + Iptables.RULE_NAME_ALLOWED_CHARS + r']+\]', comment
2020-08-18 21:44:49 +00:00
):
filtered_rules.append(line)
else:
# Fail if there is no comment after the --comment parameter
2020-08-22 12:49:44 +00:00
msg = 'Iptables rule is missing a comment after ' \
"the '--comment' parameter:\n%s" % line
2020-08-18 21:44:49 +00:00
Iptables.module.fail_json(msg=msg)
2020-08-22 12:49:44 +00:00
# If it doesn't have comment, this means it is not managed by Ansible
2020-08-18 21:44:49 +00:00
# and we should append it.
else:
filtered_rules.append(line)
else:
filtered_rules.append(line)
2020-08-22 12:49:44 +00:00
return '\n'.join(filtered_rules)
2020-08-18 21:44:49 +00:00
# Same as _filter_rules(), but returns custom chains
def _filter_custom_chains(self, rules, table, only_unmanaged=False):
filtered_chains = []
# Get list of managed custom chains, which is needed to detect unmanaged custom chains
managed_custom_chains_list = self._get_custom_chains_list(table)
for line in rules.splitlines():
if Iptables.is_custom_chain(line, table):
if only_unmanaged:
2020-08-22 12:49:44 +00:00
# The chain is not managed by this module if it's not
2020-08-18 21:44:49 +00:00
# in the list of managed custom chains.
chain_name = self._get_custom_chain_name(line, table)
if chain_name not in managed_custom_chains_list:
filtered_chains.append(line)
else:
filtered_chains.append(line)
2020-08-22 12:49:44 +00:00
return '\n'.join(filtered_chains)
2020-08-18 21:44:49 +00:00
# Returns list of custom chains of a table.
def _get_custom_chains_list(self, table):
custom_chains_list = []
for key, value in self._get_table_rules_dict(table).items():
# Ignore UNMANAGED_RULES_KEY_NAME key, since we only want managed custom chains.
if key != Iptables.UNMANAGED_RULES_KEY_NAME:
2020-08-22 12:49:44 +00:00
for line in value['rules'].splitlines():
2020-08-18 21:44:49 +00:00
if Iptables.is_custom_chain(line, table):
chain_name = self._get_custom_chain_name(line, table)
if chain_name not in custom_chains_list:
custom_chains_list.append(chain_name)
return custom_chains_list
2020-08-22 12:49:44 +00:00
# Prepends 'ansible[name]: ' to iptables rule '--comment' argument,
# or adds 'ansible[name]' as a comment if there is no comment.
2020-08-18 21:44:49 +00:00
def _prepend_ansible_comment(self, rules, name):
commented_lines = []
for line in rules.splitlines():
# Extract rules only since we cannot add comments to custom chains.
if Iptables.is_rule(line):
tokens = self._split_rule_into_tokens(line)
2020-08-22 12:49:44 +00:00
if '--comment' in tokens:
# If there is a comment parameter, we need to prepand 'ansible[name]: '.
comment_index = tokens.index('--comment') + 1
2020-08-18 21:44:49 +00:00
if comment_index < len(tokens):
# We need to remove double quotes from comments, since there
# is an incompatiblity with older iptables versions
2020-08-22 12:49:44 +00:00
comment_text = tokens[comment_index].replace('"', '')
tokens[comment_index] = 'ansible[' + name + ']: ' + comment_text
2020-08-18 21:44:49 +00:00
else:
# Fail if there is no comment after the --comment parameter
2020-08-22 12:49:44 +00:00
msg = 'Iptables rule is missing a comment after ' \
"the '--comment' parameter:\n%s" % line
2020-08-18 21:44:49 +00:00
Iptables.module.fail_json(msg=msg)
else:
2020-08-22 12:49:44 +00:00
# If comment doesn't exist, we add a comment 'ansible[name]'
tokens += ['-m', 'comment', '--comment', 'ansible[' + name + ']']
2020-08-18 21:44:49 +00:00
# Escape and quote tokens in case they have spaces
tokens = [self._escape_and_quote_string(x) for x in tokens]
2020-08-22 12:49:44 +00:00
commented_lines.append(' '.join(tokens))
# Otherwise it's a chain, and we should just return it.
2020-08-18 21:44:49 +00:00
else:
commented_lines.append(line)
2020-08-22 12:49:44 +00:00
return '\n'.join(commented_lines)
2020-08-18 21:44:49 +00:00
# Double quote a string if it contains a space and escape double quotes.
def _escape_and_quote_string(self, s):
2020-08-22 12:49:44 +00:00
escaped = s.replace('"', r'\"')
if re.search(r'\s', escaped):
return '"' + escaped + '"'
2020-08-18 21:44:49 +00:00
else:
return escaped
# Add table rule to the state_dict.
def add_table_rule(self, table, name, weight, rules, prepend_ansible_comment=True):
self._fail_on_bad_rules(rules, table)
if prepend_ansible_comment:
2020-08-22 12:49:44 +00:00
self.state_dict[table]['rules_dict'][name] = {
'weight': weight,
'rules': self._prepend_ansible_comment(rules, name)
2020-08-18 21:44:49 +00:00
}
else:
2020-08-22 12:49:44 +00:00
self.state_dict[table]['rules_dict'][name] = {'weight': weight, 'rules': rules}
2020-08-18 21:44:49 +00:00
# Remove table rule from the state_dict.
def remove_table_rule(self, table, name):
2020-08-22 12:49:44 +00:00
if name in self.state_dict[table]['rules_dict']:
del self.state_dict[table]['rules_dict'][name]
2020-08-18 21:44:49 +00:00
# TODO: Add sorting of rules so that diffs in check_mode look nicer and easier to follow.
# Sorting would be done from top to bottom like this:
# * default chain policies
# * custom chains
# * rules
#
# Converts rules from a state_dict to an iptables-save readable format.
def get_table_rules(self, table):
2020-08-22 12:49:44 +00:00
generated_rules = ''
# We first add a header e.g. '*filter'.
generated_rules += '*' + table + '\n'
2020-08-18 21:44:49 +00:00
rules_list = []
custom_chains_list = []
default_chain_policies = []
dict_rules = self._get_table_rules_dict(table)
2020-08-22 12:49:44 +00:00
# Return list of rule names sorted by ('weight', 'rules') tuple.
2020-08-18 21:44:49 +00:00
for rule_name in sorted(
2020-08-22 12:49:44 +00:00
dict_rules, key=lambda x: (dict_rules[x]['weight'], dict_rules[x]['rules'])
2020-08-18 21:44:49 +00:00
):
2020-08-22 12:49:44 +00:00
rules = dict_rules[rule_name]['rules']
2020-08-18 21:44:49 +00:00
# Fail if some of the rules are bad
self._fail_on_bad_rules(rules, table)
rules_list.append(self._filter_rules(rules, table))
custom_chains_list.append(self._filter_custom_chains(rules, table))
default_chain_policies.append(self._filter_default_chain_policies(rules, table))
# Clean up empty strings from these two lists.
rules_list = list(filter(None, rules_list))
custom_chains_list = list(filter(None, custom_chains_list))
default_chain_policies = list(filter(None, default_chain_policies))
if default_chain_policies:
# Since iptables-restore applies the last chain policy it reads, we
# have to reverse the order of chain policies so that those with
# the lowest weight (higher priority) are read last.
2020-08-22 12:49:44 +00:00
generated_rules += '\n'.join(reversed(default_chain_policies)) + '\n'
2020-08-18 21:44:49 +00:00
if custom_chains_list:
# We remove duplicate custom chains so that iptables-restore
2020-08-22 12:49:44 +00:00
# doesn't fail because of that.
2020-08-18 21:44:49 +00:00
generated_rules += self._remove_duplicate_custom_chains(
2020-08-22 12:49:44 +00:00
'\n'.join(sorted(custom_chains_list)), table
) + '\n'
2020-08-18 21:44:49 +00:00
if rules_list:
2020-08-22 12:49:44 +00:00
generated_rules += '\n'.join(rules_list) + '\n'
generated_rules += 'COMMIT\n'
2020-08-18 21:44:49 +00:00
return generated_rules
# Sets unmanaged rules for the passed table in the state_dict.
def _set_unmanaged_rules(self, table, rules):
self.add_table_rule(
table, Iptables.UNMANAGED_RULES_KEY_NAME, 90, rules, prepend_ansible_comment=False
)
# Clears unmanaged rules of a table.
def clear_unmanaged_rules(self, table):
2020-08-22 12:49:44 +00:00
self._set_unmanaged_rules(table, '')
2020-08-18 21:44:49 +00:00
# Updates unmanaged rules of a table from the active rules.
def refresh_unmanaged_rules(self, table):
# Get active iptables rules and clean them up.
active_rules = self._get_active_rules(table)
unmanaged_chains_and_rules = []
unmanaged_chains_and_rules.append(
self._filter_custom_chains(active_rules, table, only_unmanaged=True)
)
unmanaged_chains_and_rules.append(
self._filter_rules(active_rules, table, only_unmanaged=True)
)
# Clean items which are empty strings
unmanaged_chains_and_rules = list(filter(None, unmanaged_chains_and_rules))
2020-08-22 12:49:44 +00:00
self._set_unmanaged_rules(table, '\n'.join(unmanaged_chains_and_rules))
2020-08-18 21:44:49 +00:00
# Check if there are bad lines in the specified rules.
def _fail_on_bad_rules(self, rules, table):
for line in rules.splitlines():
tokens = self._split_rule_into_tokens(line)
2020-08-22 12:49:44 +00:00
if '-t' in tokens or '--table' in tokens:
2020-08-18 21:44:49 +00:00
msg = (
"Iptables rules cannot contain '-t/--table' parameter. "
"You should use the 'table' parameter of the module to set rules "
2020-08-22 12:49:44 +00:00
'for a specific table.'
2020-08-18 21:44:49 +00:00
)
Iptables.module.fail_json(msg=msg)
2020-08-22 12:49:44 +00:00
# Fail if the parameter --comment doesn't have a comment after
if '--comment' in tokens and len(tokens) <= tokens.index('--comment') + 1:
msg = 'Iptables rule is missing a comment after ' \
"the '--comment' parameter:\n%s" % line
2020-08-18 21:44:49 +00:00
Iptables.module.fail_json(msg=msg)
if not (
Iptables.is_rule(line) or Iptables.is_custom_chain(line, table)
or Iptables.is_default_chain(line, table) or Iptables.is_comment(line)
):
msg = (
2020-08-22 12:49:44 +00:00
"Bad iptables rule '%s'! You can only use -A/--append, -N/--new-chain "
'and -P/--policy to specify rules.' % line
2020-08-18 21:44:49 +00:00
)
Iptables.module.fail_json(msg=msg)
# Write rules to dest path.
def _write_rules_to_file(self, rules, dest):
tmp_path = self._write_to_temp_file(rules)
Iptables.module.atomic_move(tmp_path, dest)
# Write text to a temp file and return path to that file.
def _write_to_temp_file(self, text):
fd, path = tempfile.mkstemp()
Iptables.module.add_cleanup_file(path) # add file for cleanup later
2020-08-22 12:49:44 +00:00
tmp = os.fdopen(fd, 'w')
2020-08-18 21:44:49 +00:00
tmp.write(text)
tmp.close()
return path
#
# Public and private methods which make changes on the system
2020-08-22 12:49:44 +00:00
# are named 'system_*' and '_system_*', respectively.
2020-08-18 21:44:49 +00:00
#
# Flush all rules in a passed table.
def _system_flush_single_table_rules(self, table):
# Set all default chain policies to ACCEPT.
for chain in Iptables.DEFAULT_CHAINS[table]:
2020-08-22 12:49:44 +00:00
cmd = [self.bins['iptables'], '-t', table, '-P', chain, 'ACCEPT']
2020-08-18 21:44:49 +00:00
Iptables.module.run_command(cmd, check_rc=True)
# Then flush all rules.
2020-08-22 12:49:44 +00:00
cmd = [self.bins['iptables'], '-t', table, '-F']
2020-08-18 21:44:49 +00:00
Iptables.module.run_command(cmd, check_rc=True)
# And delete custom chains.
2020-08-22 12:49:44 +00:00
cmd = [self.bins['iptables'], '-t', table, '-X']
2020-08-18 21:44:49 +00:00
Iptables.module.run_command(cmd, check_rc=True)
# Update active rules in the object.
self._refresh_active_rules(table)
# Save active iptables rules to the system path.
def _system_save_active(self, backup=False):
# Backup if needed
if backup:
Iptables.module.backup_local(self.system_save_path)
# Get iptables-save dump of all tables
2020-08-22 12:49:44 +00:00
all_active_rules = self._get_active_rules(table='*', clean=False)
2020-08-18 21:44:49 +00:00
# Move iptables-save dump of all tables to the iptables_save_path
self._write_rules_to_file(all_active_rules, self.system_save_path)
# Apply table dict rules to the system.
def system_apply_table_rules(self, table, test=False):
dump_path = self._write_to_temp_file(self.get_table_rules(table))
if test:
2020-08-22 12:49:44 +00:00
cmd = [self.bins['iptables-restore'], '-t', dump_path]
2020-08-18 21:44:49 +00:00
else:
2020-08-22 12:49:44 +00:00
cmd = [self.bins['iptables-restore'], dump_path]
2020-08-18 21:44:49 +00:00
rc, stdout, stderr = Iptables.module.run_command(cmd, check_rc=False)
if rc != 0:
if test:
2020-08-22 12:49:44 +00:00
dump_contents_file = open(dump_path, 'r')
2020-08-18 21:44:49 +00:00
dump_contents = dump_contents_file.read()
dump_contents_file.close()
2020-08-22 12:49:44 +00:00
msg = 'There is a problem with the iptables rules:' \
+ '\n\nError message:\n' \
2020-08-18 21:44:49 +00:00
+ stderr \
2020-08-22 12:49:44 +00:00
+ '\nGenerated rules:\n#######\n' \
+ dump_contents + '#####'
2020-08-18 21:44:49 +00:00
else:
2020-08-22 12:49:44 +00:00
msg = 'Could not load iptables rules:\n\n' + stderr
2020-08-18 21:44:49 +00:00
Iptables.module.fail_json(msg=msg)
self._refresh_active_rules(table)
2020-08-22 12:49:44 +00:00
# Flush one or all tables (to flush all tables pass table='*').
2020-08-18 21:44:49 +00:00
def system_flush_table_rules(self, table):
2020-08-22 12:49:44 +00:00
if table == '*':
2020-08-18 21:44:49 +00:00
for tbl in Iptables.TABLES:
self._delete_table(tbl)
if self._single_table_needs_flush(tbl):
self._system_flush_single_table_rules(tbl)
# Only flush the specified table.
else:
self._delete_table(table)
if self._single_table_needs_flush(table):
self._system_flush_single_table_rules(table)
# Saves state file and system iptables rules.
def system_save(self, backup=False):
self._system_save_active(backup=backup)
2020-08-22 12:49:44 +00:00
rules = json.dumps(self.state_dict, sort_keys=True, indent=4, separators=(',', ': '))
2020-08-18 21:44:49 +00:00
self._write_rules_to_file(rules, self.state_save_path)
def main():
module = AnsibleModule(
argument_spec=dict(
2020-08-22 12:49:44 +00:00
ipversion=dict(required=False, choices=['4', '6'], type='str', default='4'),
2020-08-18 21:44:49 +00:00
state=dict(
2020-08-22 12:49:44 +00:00
required=False, choices=['present', 'absent'], default='present', type='str'
2020-08-18 21:44:49 +00:00
),
2020-08-22 12:49:44 +00:00
weight=dict(required=False, type='int', default=40),
name=dict(required=True, type='str'),
2020-08-18 21:44:49 +00:00
table=dict(
2020-08-22 12:49:44 +00:00
required=False, choices=Iptables.TABLES + ['*'], default='filter', type='str'
2020-08-18 21:44:49 +00:00
),
2020-08-22 12:49:44 +00:00
rules=dict(required=False, type='str', default=''),
backup=dict(required=False, type='bool', default=False),
keep_unmanaged=dict(required=False, type='bool', default=True),
2020-08-18 21:44:49 +00:00
),
supports_check_mode=True,
)
check_mode = module.check_mode
changed = False
2020-08-22 12:49:44 +00:00
ipversion = module.params['ipversion']
state = module.params['state']
weight = module.params['weight']
name = module.params['name']
table = module.params['table']
rules = module.params['rules']
backup = module.params['backup']
keep_unmanaged = module.params['keep_unmanaged']
2020-08-18 21:44:49 +00:00
kw = dict(
state=state,
name=name,
rules=rules,
weight=weight,
ipversion=ipversion,
table=table,
backup=backup,
keep_unmanaged=keep_unmanaged
)
iptables = Iptables(module, ipversion)
# Acquire lock so that only one instance of this object can exist.
# Fail if the lock cannot be acquired within 10 seconds.
iptables.acquire_lock_or_exit(wait_for_seconds=10)
# Clean up rules of comments and empty lines.
rules = Iptables.clean_up_rules(rules)
# Check additional parameter requirements
2020-08-22 12:49:44 +00:00
if state == 'present' and name == '*':
2020-08-18 21:44:49 +00:00
module.fail_json(msg="Parameter 'name' can only be '*' if 'state=absent'")
2020-08-22 12:49:44 +00:00
if state == 'present' and table == '*':
2020-08-18 21:44:49 +00:00
module.fail_json(msg="Parameter 'table' can only be '*' if 'name=*' and 'state=absent'")
2020-08-22 12:49:44 +00:00
if state == 'present' and not name:
2020-08-18 21:44:49 +00:00
module.fail_json(msg="Parameter 'name' cannot be empty")
2020-08-22 12:49:44 +00:00
if state == 'present' and not re.match('^[' + Iptables.RULE_NAME_ALLOWED_CHARS + ']+$', name):
2020-08-18 21:44:49 +00:00
module.fail_json(
msg="Parameter 'name' not valid! It can only contain alphanumeric characters, "
2020-08-22 12:49:44 +00:00
"underscore, hyphen, or a space, got: '%s'" % name
2020-08-18 21:44:49 +00:00
)
if weight < 0 or weight > 99:
2020-08-22 12:49:44 +00:00
module.fail_json(msg="Parameter 'weight' can be 0-99, got: %d" % weight)
if state == 'present' and rules == '':
2020-08-18 21:44:49 +00:00
module.fail_json(msg="Parameter 'rules' cannot be empty when 'state=present'")
# Flush rules of one or all tables
2020-08-22 12:49:44 +00:00
if state == 'absent' and name == '*':
2020-08-18 21:44:49 +00:00
# Check if table(s) need to be flushed
if iptables.table_needs_flush(table):
changed = True
if not check_mode:
# Flush table(s)
iptables.system_flush_table_rules(table)
# Save state and system iptables rules
iptables.system_save(backup=backup)
# Exit since there is nothing else to do
2020-08-22 12:49:44 +00:00
kw['changed'] = changed
2020-08-18 21:44:49 +00:00
module.exit_json(**kw)
# Initialize new iptables object which will store new rules
iptables_new = Iptables(module, ipversion)
2020-08-22 12:49:44 +00:00
if state == 'present':
2020-08-18 21:44:49 +00:00
iptables_new.add_table_rule(table, name, weight, rules)
else:
iptables_new.remove_table_rule(table, name)
if keep_unmanaged:
iptables_new.refresh_unmanaged_rules(table)
else:
iptables_new.clear_unmanaged_rules(table)
# Refresh saved table dump with active iptables rules
iptables_new.refresh_saved_table_dump(table)
# Check if there are changes in iptables, and if yes load new rules
if iptables != iptables_new:
changed = True
# Test generated rules
iptables_new.system_apply_table_rules(table, test=True)
if check_mode:
# Create a predicted diff for check_mode.
# Diff will be created from rules generated from the state dictionary.
2020-08-22 12:49:44 +00:00
if hasattr(module, '_diff') and module._diff:
2020-08-18 21:44:49 +00:00
# Update unmanaged rules in the old object so the generated diff
# from the rules dictionaries is more accurate.
iptables.refresh_unmanaged_rules(table)
# Generate table rules from rules dictionaries.
table_rules_old = iptables.get_table_rules(table)
table_rules_new = iptables_new.get_table_rules(table)
# If rules generated from dicts are not equal, we generate a diff from them.
if table_rules_old != table_rules_new:
2020-08-22 12:49:44 +00:00
kw['diff'] = generate_diff(table_rules_old, table_rules_new)
2020-08-18 21:44:49 +00:00
else:
# TODO: Update this comment to be better.
2020-08-22 12:49:44 +00:00
kw['diff'] = {
'prepared':
'System rules were not changed (e.g. rule '
'weight changed, redundant rule, etc)'
2020-08-18 21:44:49 +00:00
}
else:
# We need to fetch active table dump before we apply new rules
# since we will need them to generate a diff.
table_active_rules = iptables_new.get_saved_table_dump(table)
# Apply generated rules.
iptables_new.system_apply_table_rules(table)
# Refresh saved table dump with active iptables rules.
iptables_new.refresh_saved_table_dump(table)
# Save state and system iptables rules.
iptables_new.system_save(backup=backup)
# Generate a diff.
2020-08-22 12:49:44 +00:00
if hasattr(module, '_diff') and module._diff:
2020-08-18 21:44:49 +00:00
table_active_rules_new = iptables_new.get_saved_table_dump(table)
if table_active_rules != table_active_rules_new:
2020-08-22 12:49:44 +00:00
kw['diff'] = generate_diff(table_active_rules, table_active_rules_new)
2020-08-18 21:44:49 +00:00
else:
# TODO: Update this comment to be better.
2020-08-22 12:49:44 +00:00
kw['diff'] = {
'prepared':
'System rules were not changed (e.g. rule '
'weight changed, redundant rule, etc)'
2020-08-18 21:44:49 +00:00
}
2020-08-22 12:49:44 +00:00
kw['changed'] = changed
2020-08-18 21:44:49 +00:00
module.exit_json(**kw)
2020-08-22 12:49:44 +00:00
if __name__ == '__main__':
2020-08-18 21:44:49 +00:00
main()