From f39e965fb2f50d23d4f3be756f6bfe8680e5b0db Mon Sep 17 00:00:00 2001 From: "Brian C. Lane" Date: Mon, 15 Apr 2019 11:55:51 -0700 Subject: [PATCH] lorax-composer: Add firewall support to blueprints You can now open ports in the firewall, using port numbers or service names: [customizations.firewall] ports = ["22:tcp", "80:tcp", "imap:tcp", "53:tcp", "53:udp"] Or enable/disable services registered with firewalld: [customizations.firewall.services] enabled = ["ftp", "ntp", "dhcp"] disabled = ["telnet"] If the template contains firewall --disabled it cannot be overridden, under the assumption that it is required for the image to boot in the selected environment. (cherry picked from commit 4d35668ab5f87e0d5f1d2e48bb7683e3b7035212) (cherry picked from commit 9f1756cc27469626af538e7b76d3d2e8da0647db) --- docs/lorax-composer.rst | 8 ++- src/pylorax/api/compose.py | 50 ++++++++++++++++ tests/pylorax/test_compose.py | 105 ++++++++++++++++++++++++++++++++++ tests/pylorax/test_recipes.py | 50 ++++++++++++++++ 4 files changed, 210 insertions(+), 3 deletions(-) diff --git a/docs/lorax-composer.rst b/docs/lorax-composer.rst index cc4f3f78..043c273e 100644 --- a/docs/lorax-composer.rst +++ b/docs/lorax-composer.rst @@ -292,9 +292,8 @@ By default the firewall blocks all access except for services that enable their like ``sshd``. This command can be used to open other ports or services. Ports are configured using the port:protocol format:: - [customizations.firewall.ports] - enabled = ["80:tcp", "imap:tcp", "53:tcp", "53:udp"] - disabled = ["23:tcp", "mysql:tcp"] + [customizations.firewall] + ports = ["22:tcp", "80:tcp", "imap:tcp", "53:tcp", "53:udp"] Numeric ports, or their names from ``/etc/services`` can be used in the ``ports`` enabled/disabled lists. @@ -303,12 +302,15 @@ in a ``customizations.firewall.services`` section:: [customizations.firewall.services] enabled = ["ftp", "ntp", "dhcp"] + disabled = ["telnet"] Note that these are different from the names in ``/etc/services``, and only ``enabled`` is supported. Both are optional, if they are not used leave them out or set them to an empty list ``[]``. If you only want the default firewall setup this section can be omitted from the blueprint. +NOTE: The ``Google`` and ``OpenStack`` templates explicitly disable the firewall for their environment. +This cannot be overridden by the blueprint. [customizations.services] ************************* diff --git a/src/pylorax/api/compose.py b/src/pylorax/api/compose.py index ce2dc4ef..01137060 100644 --- a/src/pylorax/api/compose.py +++ b/src/pylorax/api/compose.py @@ -281,6 +281,53 @@ def get_keyboard_layout(recipe): return recipe["customizations"]["locale"]["keyboard"] +def firewall_cmd(line, settings): + """ Update the firewall line with the new ports and services + + :param line: The firewall ... line + :type line: str + :param settings: A dict with the list of services and ports to enable and disable + :type settings: dict + + Using pykickstart to process the line is the best way to make sure it + is parsed correctly, and re-assembled for inclusion into the final kickstart + """ + ks_version = makeVersion() + ks = KickstartParser(ks_version, errorsAreFatal=False, missingIncludeIsFatal=False) + ks.readKickstartFromString(line) + + # Do not override firewall --disabled + if ks.handler.firewall.enabled != False and settings: + ks.handler.firewall.ports = settings["ports"] + ks.handler.firewall.services = settings["enabled"] + ks.handler.firewall.remove_services = settings["disabled"] + + # Converting back to a string includes a comment, return just the keyboard line + return str(ks.handler.firewall).splitlines()[-1] + + +def get_firewall_settings(recipe): + """Return the customizations.firewall settings + + :param recipe: The recipe + :type recipe: Recipe object + :returns: A dict of settings + :rtype: dict + """ + settings = {"ports": [], "enabled": [], "disabled": []} + + if "customizations" not in recipe or \ + "firewall" not in recipe["customizations"]: + return settings + + settings["ports"] = recipe["customizations"]["firewall"].get("ports", []) + + if "services" in recipe["customizations"]["firewall"]: + settings["enabled"] = recipe["customizations"]["firewall"]["services"].get("enabled", []) + settings["disabled"] = recipe["customizations"]["firewall"]["services"].get("disabled", []) + return settings + + def customize_ks_template(ks_template, recipe): """ Customize the kickstart template and return it @@ -314,6 +361,9 @@ def customize_ks_template(ks_template, recipe): "keyboard": [keyboard_cmd, get_keyboard_layout(recipe), 'keyboard --xlayouts us --vckeymap us', True], + "firewall": [firewall_cmd, + get_firewall_settings(recipe), + 'firewall --enabled', True], } found = {} diff --git a/tests/pylorax/test_compose.py b/tests/pylorax/test_compose.py index 7b45e606..db6694ac 100644 --- a/tests/pylorax/test_compose.py +++ b/tests/pylorax/test_compose.py @@ -24,6 +24,7 @@ from pylorax import get_buildarch from pylorax.api.compose import add_customizations, get_extra_pkgs, compose_types from pylorax.api.compose import timezone_cmd, get_timezone_settings from pylorax.api.compose import lang_cmd, get_languages, keyboard_cmd, get_keyboard_layout +from pylorax.api.compose import firewall_cmd, get_firewall_settings from pylorax.api.compose import get_kernel_append, bootloader_append, customize_ks_template from pylorax.api.config import configure, make_dnf_dirs from pylorax.api.dnfbase import get_base_object @@ -389,6 +390,65 @@ languages = ["en_CA.utf8", "en_HK.utf8"] "de (dvorak)"), "keyboard 'de (dvorak)'") + def test_get_firewall_settings(self): + """Test get_firewall_settings function""" + blueprint_data = """name = "test-firewall" +description = "test recipe" +version = "0.0.1" + """ + firewall_ports = """ +[customizations.firewall] +ports = ["22:tcp", "80:tcp", "imap:tcp", "53:tcp", "53:udp"] +""" + firewall_services = """ +[customizations.firewall.services] +enabled = ["ftp", "ntp", "dhcp"] +disabled = ["telnet"] +""" + blueprint2_data = blueprint_data + firewall_ports + blueprint3_data = blueprint_data + firewall_services + blueprint4_data = blueprint_data + firewall_ports + firewall_services + + recipe = recipe_from_toml(blueprint_data) + self.assertEqual(get_firewall_settings(recipe), {'ports': [], 'enabled': [], 'disabled': []}) + + recipe = recipe_from_toml(blueprint2_data) + self.assertEqual(get_firewall_settings(recipe), + {"ports": ["22:tcp", "80:tcp", "imap:tcp", "53:tcp", "53:udp"], + "enabled": [], "disabled": []}) + + recipe = recipe_from_toml(blueprint3_data) + self.assertEqual(get_firewall_settings(recipe), + {"ports": [], + "enabled": ["ftp", "ntp", "dhcp"], "disabled": ["telnet"]}) + + recipe = recipe_from_toml(blueprint4_data) + self.assertEqual(get_firewall_settings(recipe), + {"ports": ["22:tcp", "80:tcp", "imap:tcp", "53:tcp", "53:udp"], + "enabled": ["ftp", "ntp", "dhcp"], "disabled": ["telnet"]}) + + def test_firewall_cmd(self): + """Test firewall_cmd function""" + + self.assertEqual(firewall_cmd("firewall --enabled", {}), "firewall --enabled") + self.assertEqual(firewall_cmd("firewall --enabled", + {"ports": ["22:tcp", "80:tcp", "imap:tcp", "53:tcp", "53:udp"], + "enabled": [], "disabled": []}), + "firewall --enabled --port=22:tcp,80:tcp,imap:tcp,53:tcp,53:udp") + self.assertEqual(firewall_cmd("firewall --enabled", + {"ports": ["22:tcp", "80:tcp", "imap:tcp", "53:tcp", "53:udp"], + "enabled": ["ftp", "ntp", "dhcp"], "disabled": []}), + "firewall --enabled --port=22:tcp,80:tcp,imap:tcp,53:tcp,53:udp --service=ftp,ntp,dhcp") + self.assertEqual(firewall_cmd("firewall --enabled", + {"ports": ["22:tcp", "80:tcp", "imap:tcp", "53:tcp", "53:udp"], + "enabled": ["ftp", "ntp", "dhcp"], "disabled": ["telnet"]}), + "firewall --enabled --port=22:tcp,80:tcp,imap:tcp,53:tcp,53:udp --service=ftp,ntp,dhcp --remove-service=telnet") + # Make sure that --disabled overrides setting ports and services + self.assertEqual(firewall_cmd("firewall --disabled", + {"ports": ["22:tcp", "80:tcp", "imap:tcp", "53:tcp", "53:udp"], + "enabled": ["ftp", "ntp", "dhcp"], "disabled": ["telnet"]}), + "firewall --disabled") + def _checkBootloader(self, result, append_str, line_limit=0): """Find the bootloader line and make sure append_str is in it""" # Optionally check to make sure the change is at the top of the template @@ -454,6 +514,28 @@ languages = ["en_CA.utf8", "en_HK.utf8"] line_num += 1 return False + def _checkFirewall(self, result, settings, line_limit=0): + """Find the firewall line and make sure it is as expected""" + # Optionally check to make sure the change is at the top of the template + line_num = 0 + for line in result.splitlines(): + if line.startswith("firewall"): + # First layout is used twice, so total count should be n+1 + ports = all([bool(p in line) for p in settings["ports"]]) + enabled = all([bool(e in line) for e in settings["enabled"]]) + disabled = all([bool(d in line) for d in settings["disabled"]]) + + if ports and enabled and disabled: + if line_limit == 0 or line_num < line_limit: + return True + else: + print("FAILED: firewall not in the first %d lines of the output" % line_limit) + return False + else: + print("FAILED: %s not matching %s" % (settings, line)) + line_num += 1 + return False + def test_template_defaults(self): """Test that customize_ks_template includes defaults correctly""" blueprint_data = """name = "test-kernel" @@ -506,6 +588,13 @@ ntpservers = ["0.north-america.pool.ntp.org", "1.north-america.pool.ntp.org"] [customizations.locale] keyboard = "de (dvorak)" languages = ["en_CA.utf8", "en_HK.utf8"] + +[customizations.firewall] +ports = ["22:tcp", "80:tcp", "imap:tcp", "53:tcp", "53:udp"] + +[customizations.firewall.services] +enabled = ["ftp", "ntp", "dhcp"] +disabled = ["telnet"] """ tz_dict = {"timezone": "US/Samoa", "ntpservers": ["0.north-america.pool.ntp.org", "1.north-america.pool.ntp.org"]} recipe = recipe_from_toml(blueprint_data) @@ -520,6 +609,10 @@ languages = ["en_CA.utf8", "en_HK.utf8"] self.assertEqual(sum([1 for l in result.splitlines() if l.startswith("lang")]), 1) self.assertTrue(self._checkKeyboard(result, "de (dvorak)", line_limit=4)) self.assertEqual(sum([1 for l in result.splitlines() if l.startswith("keyboard")]), 1) + self.assertTrue(self._checkFirewall(result, + {"ports": ["22:tcp", "80:tcp", "imap:tcp", "53:tcp", "53:udp"], + "enabled": ["ftp", "ntp", "dhcp"], "disabled": ["telnet"]}, line_limit=6)) + self.assertEqual(sum([1 for l in result.splitlines() if l.startswith("firewall")]), 1) # Test against a kickstart with a bootloader line result = customize_ks_template("firewall --enabled\nbootloader --location=mbr\n", recipe) @@ -560,6 +653,18 @@ languages = ["en_CA.utf8", "en_HK.utf8"] if sum([1 for l in result.splitlines() if l.startswith("keyboard")]) != 1: errors.append(("keyboard for compose_type %s failed: More than 1 entry" % compose_type, result)) + # google and openstack templates requires the firewall to be disabled + if compose_type == "google" or compose_type == "openstack": + if not self._checkFirewall(result, {'ports': [], 'enabled': [], 'disabled': []}): + errors.append(("firewall for compose_type %s failed" % compose_type, result)) + else: + if not self._checkFirewall(result, + {"ports": ["22:tcp", "80:tcp", "imap:tcp", "53:tcp", "53:udp"], + "enabled": ["ftp", "ntp", "dhcp"], "disabled": ["telnet"]}): + errors.append(("firewall for compose_type %s failed" % compose_type, result)) + if sum([1 for l in result.splitlines() if l.startswith("firewall")]) != 1: + errors.append(("firewall for compose_type %s failed: More than 1 entry" % compose_type, result)) + # Print the bad results for e, r in errors: print("%s:\n%s\n\n" % (e, r)) diff --git a/tests/pylorax/test_recipes.py b/tests/pylorax/test_recipes.py index 7a1fb6b7..87c21f07 100644 --- a/tests/pylorax/test_recipes.py +++ b/tests/pylorax/test_recipes.py @@ -523,6 +523,56 @@ languages = ["en_CA.utf8", "en_HK.utf8"] self.assertEqual(ks.handler.lang.lang, "en_CA.utf8") self.assertEqual(ks.handler.lang.addsupport, ["en_HK.utf8"]) + def test_firewall_ports(self): + blueprint_data = """name = "test-firewall" +description = "test recipe" +version = "0.0.1" +""" + blueprint2_data = blueprint_data + """ +[customizations.firewall] +ports = ["22:tcp", "80:tcp", "imap:tcp", "53:tcp", "53:udp"] +""" + ks = self._blueprint_to_ks(blueprint_data) + self.assertEqual(ks.handler.firewall.ports, []) + self.assertEqual(ks.handler.firewall.services, []) + self.assertEqual(ks.handler.firewall.remove_services, []) + + ks = self._blueprint_to_ks(blueprint2_data) + self.assertEqual(ks.handler.firewall.ports, ["22:tcp", "80:tcp", "imap:tcp", "53:tcp", "53:udp"]) + self.assertEqual(ks.handler.firewall.services, []) + self.assertEqual(ks.handler.firewall.remove_services, []) + + def test_firewall_services(self): + blueprint_data = """name = "test-firewall" +description = "test recipe" +version = "0.0.1" + +[customizations.firewall.services] +enabled = ["ftp", "ntp", "dhcp"] +disabled = ["telnet"] +""" + ks = self._blueprint_to_ks(blueprint_data) + self.assertEqual(ks.handler.firewall.ports, []) + self.assertEqual(ks.handler.firewall.services, ["ftp", "ntp", "dhcp"]) + self.assertEqual(ks.handler.firewall.remove_services, ["telnet"]) + + def test_firewall(self): + blueprint_data = """name = "test-firewall" +description = "test recipe" +version = "0.0.1" + +[customizations.firewall] +ports = ["22:tcp", "80:tcp", "imap:tcp", "53:tcp", "53:udp"] + +[customizations.firewall.services] +enabled = ["ftp", "ntp", "dhcp"] +disabled = ["telnet"] +""" + ks = self._blueprint_to_ks(blueprint_data) + self.assertEqual(ks.handler.firewall.ports, ["22:tcp", "80:tcp", "imap:tcp", "53:tcp", "53:udp"]) + self.assertEqual(ks.handler.firewall.services, ["ftp", "ntp", "dhcp"]) + self.assertEqual(ks.handler.firewall.remove_services, ["telnet"]) + def test_user(self): blueprint_data = """name = "test-user" description = "test recipe"