diff --git a/.gitignore b/.gitignore index 62f2449..9e68477 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,8 @@ !.vscode/settings.json !.vscode/extensions.json .idea +\#*\# +*~ +*.pyc +pytestdebug.log +.cache diff --git a/README.md b/README.md index d087067..d5ddcc6 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,40 @@ $ git clone https://github.com/weareinteractive/ansible-ufw.git weareinteractive Here is a list of all the default variables for this role, which are also available in `defaults/main.yml`. +| Variable | Default | Comments (type) | +| :--- | :--- | :--- | +| ufw_allow_tcp_ports | | List of tcp ports/services which are open to the public | +| ufw_allow_udp_ports | | List of udp ports/services which are open to the public | +| ufw_allow_interfaces | | List of interfaces through which access is generally allowed | +| ufw_ports_acl | | List of dictionary structure containing rule configuration. Structure is documented below. | + + +ufw_ports_acl structure +----------------------- + +```yaml +ufw_ports_acl: + - name: SSH + port: 22 + ips: "{{ firewall_ssh_ips }}" + - name: SNMP + port: 161 + proto: UDP + ips: "{{ firewall_snmp_ips }}" +``` + +| Variable | Default | Comments (type) | +| :--- | :--- | :--- | +| name | | A name identifying the rule | +| port | | Destination port | +| proto | | Protocol to be used | +| ips | | List of source IP addresses | + + + + + + ```yaml --- # Start the service and enable it on system boot @@ -74,6 +108,37 @@ ufw_config: # Path to the configuration file ufw_config_file: /etc/default/ufw +ufw_allow_tcp_ports: + - 80 + - 443 + - ssh + +ufw_allow_udp_ports: + - 161 + +ufw_allow_interfaces: + - openvpntun+ + +firewall_ssh_ips: "{{ firewall_ssh_ips_default }}" +firewall_ssh_ips_default: + - x.x.x.x + - y.y.y.y + +firewall_snmp_ips: "{{ firewall_snmp_ips_default }}" +firewall_snmp_ips_default: + - a.a.a.a + - b.b.b.b + +ufw_ports_acl: + - name: SSH + port: 22 + ips: "{{ firewall_ssh_ips }}" + - name: SNMP + port: 161 + proto: UDP + ips: "{{ firewall_snmp_ips }}" + + ``` ## Handlers diff --git a/defaults/main.yml b/defaults/main.yml index f6e62a4..8f5b6ca 100644 --- a/defaults/main.yml +++ b/defaults/main.yml @@ -30,3 +30,21 @@ ufw_config: # Path to the configuration file ufw_config_file: /etc/default/ufw + +# List of TCP ports to be opened to the public +ufw_allow_tcp_ports: [] + +# List of UDP ports to be opened to the public +ufw_allow_udp_ports: [] + +# List of interfaces to be opened to the public +ufw_allow_interfaces: [] + +# List of rules to be opened. Format of the structure: +# - name: +# port: 22 +# proto: TCP (default) +# ips: +# - a.a.a.a +# - b.b.b.b +ufw_ports_acl: [] diff --git a/molecule/default/converge.yml b/molecule/default/converge.yml index 888057b..95dab75 100644 --- a/molecule/default/converge.yml +++ b/molecule/default/converge.yml @@ -5,8 +5,29 @@ collections: - community.general vars: - ufw_enabled: false + ufw_enabled: true ufw_rules: [] + ufw_allow_tcp_ports: + - 80 + - https + ufw_allow_udp_ports: + - 161 + - 162 + ufw_allow_interfaces: + - tun0 + - openvpntap0 + ufw_ports_acl: + - name: SSH + port: 22 + ips: + - 10.0.0.0/8 + - 192.168.0.0/16 + - name: SIP + port: 5060 + proto: UDP + ips: + - 10.0.0.0/24 + - 192.168.0.0/24 pre_tasks: - name: Update apt cache. diff --git a/molecule/default/molecule.yml b/molecule/default/molecule.yml index 79d2e15..3f1382b 100644 --- a/molecule/default/molecule.yml +++ b/molecule/default/molecule.yml @@ -4,7 +4,6 @@ dependency: driver: name: docker lint: | - set -e yamllint . ansible-lint platforms: @@ -21,4 +20,7 @@ provisioner: playbooks: converge: ${MOLECULE_PLAYBOOK:-converge.yml} verifier: - name: ansible + name: testinfra + options: + v: 1 + sudo: true diff --git a/molecule/default/tests/test_default.py b/molecule/default/tests/test_default.py new file mode 100644 index 0000000..297a0b6 --- /dev/null +++ b/molecule/default/tests/test_default.py @@ -0,0 +1,145 @@ +# -*- coding: utf-8 -*- +# :Project: weareinteractive.ufw ansible role - unittests +# :Created: Fri 17 Sep 2021 21:31:46 CEST +# :Author: Peter Warasin +# :License: GPLv2 +# :Copyright: © 2021 Endian s.r.l. +# + + +""" +test_default.py - default unittest file. + +This file contains unittests using testinfra used to test if the +ansible role does what we wanted that it would do. +""" + +from types import SimpleNamespace +import pytest + + +@pytest.fixture(name="facts", scope="module") +def fixture_facts(host): + """ + Set up the environment before the test. + + Setup environment by retrieving information from host which we + need for out tests. + + :param value: host + :type value: the link to the testinfra host + :return: an object holding the retrieved ansible facts + :rtype: types.SimpleNamespace + """ + ansible_facts = host.ansible("setup")["ansible_facts"] + ret = SimpleNamespace() + ret.host_os = ansible_facts["ansible_lsb"]["codename"] + return ret + + +def test_service(host): + """ + Tests if the firewalld service is enabled. + + This test method checks if the firewalld service is enabled. + + :param host: the link to the testinfra host provided by fixture + :type host: Host object + """ + service = host.service("ufw") + assert service.is_enabled + + +def test_ufw_allow_tcp_ports(host, facts): + """ + Tests if the configured TCP ports are open. + + This test method checks if iptable rules do exist which open + ports as configured in the inventory. + + :param value: host + :type value: the link to the testinfra host + :param facts: Retrieved ansible facts + :type facts: types.SimpleNamespace + """ + rules = host.iptables.rules() + + testrules = [ + "-A ufw-user-input -p tcp -m tcp --dport 80 -j ACCEPT", + "-A ufw-user-input -p tcp -m tcp --dport 443 -j ACCEPT", + ] + + for testrule in testrules: + assert testrule in rules + + +def test_ufw_allow_udp_ports(host, facts): + """ + Tests if the configured UDP ports are open. + + This test method checks if iptable rules do exist which open + ports as configured in the inventory. + + :param value: host + :type value: the link to the testinfra host + :param facts: Retrieved ansible facts + :type facts: types.SimpleNamespace + """ + rules = host.iptables.rules() + + testrules = [ + "-A ufw-user-input -p udp -m udp --dport 161 -j ACCEPT", + "-A ufw-user-input -p udp -m udp --dport 162 -j ACCEPT", + ] + + for testrule in testrules: + assert testrule in rules + + +def test_allow_interfaces(host, facts): + """ + Tests if firewall allows access from NICs configured in the inventory. + + This test method checks if iptable rules do exist which allow + all traffic coming in through the NICs configured in the inventory. + + :param value: host + :type value: the link to the testinfra host + :param facts: Retrieved ansible facts + :type facts: types.SimpleNamespace + """ + rules = host.iptables.rules() + + testrules = [ + "-A ufw-user-input -i openvpntap0 -j ACCEPT", + "-A ufw-user-input -i tun0 -j ACCEPT", + ] + + for testrule in testrules: + assert testrule in rules + + +def test_ports_acl(host, facts): + """ + Tests if ufw_ports_acl list is configured for the given IP addresses. + + This test method checks if iptable rules do exist which open + the whole list of rules defined in ufw_ports_acl which is configured + in the inventory. + + :param value: host + :type value: the link to the testinfra host + :param facts: Retrieved ansible facts + :type facts: types.SimpleNamespace + """ + rules = host.iptables.rules() + + testrules = [ + "-A ufw-user-input -s 10.0.0.0/8 -p tcp -m tcp --dport 22 -j ACCEPT", + "-A ufw-user-input -s 192.168.0.0/16 -p tcp -m tcp --dport 22 -j ACCEPT", + "-A ufw-user-input -s 10.0.0.0/24 -p udp -m udp --dport 5060 -j ACCEPT", + "-A ufw-user-input -s 192.168.0.0/24 -p udp -m udp --dport 5060 -j ACCEPT", + ] + + for testrule in testrules: + assert testrule in rules diff --git a/tasks/assert.yml b/tasks/assert.yml new file mode 100644 index 0000000..01f9ece --- /dev/null +++ b/tasks/assert.yml @@ -0,0 +1,121 @@ +--- +- name: Check if ufw_allow_tcp_ports is a list + assert: + that: + - ufw_allow_tcp_ports is iterable + msg: "'ufw_allow_tcp_ports' must be a list if set" + quiet: true + when: + - ufw_allow_tcp_ports is defined + +- name: Check if ufw_allow_tcp_ports is a list of valid format + assert: + that: + - item is string or item is number + msg: "'ufw_allow_tcp_ports' must be a list of ports or services" + quiet: true + with_items: + - "{{ ufw_allow_tcp_ports }}" + when: + - ufw_allow_tcp_ports is defined + +- name: Check if ufw_allow_udp_ports is a list + assert: + that: + - ufw_allow_udp_ports is iterable + msg: "'ufw_allow_udp_ports' must be a list if set" + quiet: true + when: + - ufw_allow_udp_ports is defined + +- name: Check if ufw_allow_udp_ports is a list of valid format + assert: + that: + - item is string or item is number + msg: "'ufw_allow_udp_ports' must be a list of ports or services" + quiet: true + with_items: + - "{{ ufw_allow_udp_ports }}" + when: + - ufw_allow_udp_ports is defined + +- name: Check if ufw_allow_interfaces is a list + assert: + that: + - ufw_allow_interfaces is iterable + msg: "'ufw_allow_interfaces' must be a list if set" + quiet: true + when: + - ufw_allow_interfaces is defined + +- name: Check if ufw_allow_interfaces is a list of valid format + assert: + that: + - item is string + msg: "'ufw_allow_interfaces' must be a list of interfaces" + quiet: true + with_items: + - "{{ ufw_allow_interfaces }}" + when: + - ufw_allow_interfaces is defined + +- name: Check if ufw_ports_acl is a list + assert: + that: + - ufw_ports_acl is iterable + msg: "'ufw_ports_acl' must be a list if set" + quiet: true + when: + - ufw_ports_acl is defined + +- name: Check if ufw_ports_acl items have correct structure + assert: + that: + - item.name is defined + - item.name is string + - item.port is defined + - item.port is string or item.port is number + msg: "'ufw_ports_acl' must be a list of rules which contain at least fields: 'name', 'port'" + quiet: true + with_items: + - "{{ ufw_ports_acl }}" + when: + - ufw_ports_acl is defined + +- name: Check if ufw_ports_acl items have correct structure + assert: + that: + - item.proto is not defined or ( item.proto is defined and item.proto | lower in ufw_valid_proto ) + msg: "Value of 'proto' in rule with name '{{ item.name }}' \ + must be one of: {{ ufw_valid_proto }}, \ + got: '{{ item.proto | default(omit) }}'." + quiet: true + with_items: + - "{{ ufw_ports_acl }}" + when: + - ufw_ports_acl is defined + +- name: Check if ufw_ports_acl items have correct structure + assert: + that: + - item.ips is defined + - item.ips is iterable + msg: "Field 'ips' in 'ufw_ports_acl' list entries must be a list of ip addresses." + quiet: true + with_items: + - "{{ ufw_ports_acl }}" + when: + - ufw_ports_acl is defined + +- name: Check if ips list of ufw_ports_acl items are in a valid ip or ip/cidr format + assert: + that: + - item[1] | ansible.netcommon.ipaddr + msg: "'ufw_ports_acl' contains an invalid ip address in 'ips' field of \ + rule with name '{{ item[0].name }}': '{{ item[1] }}'." + quiet: true + with_subelements: + - "{{ ufw_ports_acl }}" + - "ips" + when: + - ufw_ports_acl is defined diff --git a/tasks/flush_handlers.yml b/tasks/flush_handlers.yml new file mode 100644 index 0000000..6884c57 --- /dev/null +++ b/tasks/flush_handlers.yml @@ -0,0 +1,7 @@ +--- +# After this rule we want to have the firewall activated immediately. +# In order to prevent that it is never enabled if the playbook interrupts +# for some reason + +- name: Flush handlers + meta: flush_handlers diff --git a/tasks/main.yml b/tasks/main.yml index 0adeb89..e3fbb09 100644 --- a/tasks/main.yml +++ b/tasks/main.yml @@ -1,5 +1,7 @@ --- +- import_tasks: assert.yml + - import_tasks: install.yml - import_tasks: service.yml @@ -7,3 +9,9 @@ - import_tasks: config.yml - import_tasks: manage.yml + +- import_tasks: simple_whitelists.yml + +- import_tasks: manage_acl.yml + +- import_tasks: flush_handlers.yml diff --git a/tasks/manage_acl.yml b/tasks/manage_acl.yml new file mode 100644 index 0000000..ce648f6 --- /dev/null +++ b/tasks/manage_acl.yml @@ -0,0 +1,13 @@ +--- +- name: Install complex accepting rules + community.general.ufw: + proto: "{{ item[0].proto | default('tcp') | lower }}" + rule: allow + to_port: "{{ item[0].port | string }}" + from_ip: "{{ item[1] }}" + comment: "ufw_ports_acl: {{ item[0].name }}" + with_subelements: + - "{{ ufw_ports_acl }}" + - "ips" + when: + - ufw_ports_acl is defined diff --git a/tasks/simple_whitelists.yml b/tasks/simple_whitelists.yml new file mode 100644 index 0000000..86bedbe --- /dev/null +++ b/tasks/simple_whitelists.yml @@ -0,0 +1,29 @@ +--- +- name: Apply tcp port whitelist + community.general.ufw: + proto: tcp + rule: allow + to_port: "{{ item | string }}" + with_items: + - "{{ ufw_allow_tcp_ports }}" + when: + - ufw_allow_tcp_ports is defined + +- name: Apply udp port whitelist + community.general.ufw: + proto: udp + rule: allow + to_port: "{{ item | string }}" + with_items: + - "{{ ufw_allow_udp_ports }}" + when: + - ufw_allow_udp_ports is defined + +- name: Apply interfaces whitelist + community.general.ufw: + rule: allow + interface_in: "{{ item }}" + with_items: + - "{{ ufw_allow_interfaces }}" + when: + - ufw_allow_interfaces is defined diff --git a/vars/main.yml b/vars/main.yml new file mode 100644 index 0000000..187a99d --- /dev/null +++ b/vars/main.yml @@ -0,0 +1,4 @@ +--- +ufw_valid_proto: + - "tcp" + - "udp"