From 5eb03fa9923fe5e43011d3c55f7a4f19b114798c Mon Sep 17 00:00:00 2001 From: Wen Liang Date: Wed, 5 Jan 2022 22:30:20 -0500 Subject: [PATCH] Support routing tables in static routes The users want to use the policy routing (e.g. source routing), so that they can forward the packet based on the other criteria except for the destination address in the packet. In such scenario, the routing tables have to be supported beforehand in static routes, so that the users can define policy routing rules later to instruct the system which table to use to determine the correct route. Signed-off-by: Wen Liang --- README.md | 7 +- examples/route_table_support.yml | 36 +++ library/network_connections.py | 5 + .../network_lsr/argument_validator.py | 167 ++++++++++++ tests/ensure_provider_tests.py | 1 + tests/playbooks/tests_route_table.yml | 151 +++++++++++ tests/tests_route_table_nm.yml | 20 ++ tests/unit/test_network_connections.py | 239 +++++++++++++++++- 8 files changed, 620 insertions(+), 6 deletions(-) create mode 100644 examples/route_table_support.yml create mode 100644 tests/playbooks/tests_route_table.yml create mode 100644 tests/tests_route_table_nm.yml diff --git a/README.md b/README.md index 58c3d99..0a02b7b 100644 --- a/README.md +++ b/README.md @@ -473,8 +473,11 @@ The IP configuration supports the following options: Static route configuration can be specified via a list of routes given in the `route` option. The default value is an empty list. Each route is a dictionary with - the following entries: `network`, `prefix`, `gateway` and `metric`. `network` and - `prefix` specify the destination network. + the following entries: `network`, `prefix`, `gateway`, `metric` and `table`. + `network` and `prefix` specify the destination network. `table` supports both the + numeric table and named table. In order to specify the named table, the users have + to ensure the named table is properly defined in `/etc/iproute2/rt_tables` or + `/etc/iproute2/rt_tables.d/*.conf`. Note that Classless inter-domain routing (CIDR) notation or network mask notation are not supported yet. diff --git a/examples/route_table_support.yml b/examples/route_table_support.yml new file mode 100644 index 0000000..bacb713 --- /dev/null +++ b/examples/route_table_support.yml @@ -0,0 +1,36 @@ +# SPDX-License-Identifier: BSD-3-Clause +--- +- hosts: all + tasks: + - name: Add a new routing table + lineinfile: + path: /etc/iproute2/rt_tables.d/table.conf + line: "200 custom" + mode: "0644" + create: yes + + - name: Configure connection profile and specify the table in static routes + import_role: + name: linux-system-roles.network + vars: + network_connections: + - name: eth0 + type: ethernet + state: up + autoconnect: yes + ip: + dhcp4: no + address: + - 198.51.100.3/26 + route: + - network: 198.51.100.128 + prefix: 26 + gateway: 198.51.100.1 + metric: 2 + table: 30400 + - network: 198.51.100.64 + prefix: 26 + gateway: 198.51.100.6 + metric: 4 + table: custom +... diff --git a/library/network_connections.py b/library/network_connections.py index d615edb..c810b4b 100644 --- a/library/network_connections.py +++ b/library/network_connections.py @@ -1133,6 +1133,11 @@ class NMUtil: rr = NM.IPRoute.new( r["family"], r["network"], r["prefix"], r["gateway"], r["metric"] ) + if r["table"]: + NM.IPRoute.set_attribute( + rr, "table", Util.GLib().Variant.new_uint32(r["table"]) + ) + if r["family"] == socket.AF_INET: s_ip4.add_route(rr) else: diff --git a/module_utils/network_lsr/argument_validator.py b/module_utils/network_lsr/argument_validator.py index e7e028d..279ac58 100644 --- a/module_utils/network_lsr/argument_validator.py +++ b/module_utils/network_lsr/argument_validator.py @@ -5,6 +5,7 @@ from __future__ import absolute_import, division, print_function __metaclass__ = type +import os import posixpath import socket import re @@ -246,6 +247,62 @@ class ArgValidatorStr(ArgValidator): return True +class ArgValidatorRouteTable(ArgValidator): + def __init__( + self, + name, + required=False, + default_value=None, + ): + ArgValidator.__init__(self, name, required, default_value) + + def _validate_impl(self, value, name): + table = None + try: + if isinstance(value, bool): + # bool can (probably) be converted to integer type, + # but here we don't want to accept a boolean value. + pass + elif isinstance(value, int): + table = int(value) + elif isinstance(value, Util.STRING_TYPE): + try: + table = int(value) + except Exception: + table = value + except Exception: + pass + if table is None: + raise ValidationError( + name, + "route table must be the named or numeric tables but is {0}".format( + value + ), + ) + if isinstance(table, int): + if table < 1: + raise ValidationError( + name, + "route table value is {0} but cannot be less than 1".format(value), + ) + elif table > 0xFFFFFFFF: + raise ValidationError( + name, + "route table value is {0} but cannot be greater than 4294967295".format( + value + ), + ) + if isinstance(table, Util.STRING_TYPE): + if table == "": + raise ValidationError(name, "route table name cannot be empty string") + if not IPRouteUtils.ROUTE_TABLE_ALIAS_RE.match(table): + raise ValidationError( + name, "route table name contains invalid characters" + ) + + return table + + class ArgValidatorNum(ArgValidator): def __init__( # pylint: disable=too-many-arguments self, @@ -543,6 +600,7 @@ class ArgValidatorIPRoute(ArgValidatorDict): ArgValidatorNum( "metric", default_value=-1, val_min=-1, val_max=0xFFFFFFFF ), + ArgValidatorRouteTable("table"), ], default_value=None, ) @@ -1858,6 +1916,19 @@ class ArgValidator_ListConnections(ArgValidatorList): VALIDATE_ONE_MODE_NM = "nm" VALIDATE_ONE_MODE_INITSCRIPTS = "initscripts" + def validate_route_tables(self, connection, idx): + for r in connection["ip"]["route"]: + if isinstance(r["table"], Util.STRING_TYPE): + mapping = IPRouteUtils.get_route_tables_mapping() + if r["table"] in mapping: + r["table"] = mapping[r["table"]] + else: + raise ValidationError.from_connection( + idx, + "cannot find route table {0} in `/etc/iproute2/rt_tables` or " + "`/etc/iproute2/rt_tables.d/`".format(r["table"]), + ) + def validate_connection_one(self, mode, connections, idx): def _ipv4_enabled(connection): has_addrs4 = any( @@ -2012,3 +2083,99 @@ class ArgValidator_ListConnections(ArgValidatorList): "match.path is not supported by the running version of " "NetworkManger.", ) + self.validate_route_tables(connection, idx) + + +class IPRouteUtils(object): + + ROUTE_TABLE_ALIAS_RE = re.compile("^[a-zA-Z0-9_.-]+$") + + @classmethod + def _parse_route_tables_mapping(cls, file_content, mapping): + for line in file_content.split(b"\n"): + + # iproute2 only skips over leading ' ' and '\t'. + line = line.lstrip() + + # skip empty lines or comments + if not line: + continue + + # In Python 2.x, there is no new type called `bytes`. And Python 2.x + # `bytes` is just an alias to the str type + if Util.PY3: + if line[0] == ord(b"#"): + continue + else: + if line[0] == "#": + continue + + # iproute2 splits at the first space. + ll = line.split(b" ", 1) + if len(ll) != 2: + continue + line1 = ll[0] + line2 = ll[1] + + comment_space = line2.find(b" ") + if comment_space >= 0: + # when commenting the route table entry in the same line, + # iproute2 only accepts one ' ', followed by '#' + if not re.match(b"^[a-zA-Z0-9_.-]+ #", line2): + continue + line2 = line2[:comment_space] + + # convert to UTF-8 and only accept benign characters. + try: + line2 = line2.decode("utf-8") + except Exception: + continue + + if not cls.ROUTE_TABLE_ALIAS_RE.match(line2): + continue + + tableid = None + try: + tableid = int(line1) + except Exception: + if line.startswith(b"0x"): + try: + tableid = int(line1[2:], 16) + except Exception: + pass + if tableid is None or tableid < 0 or tableid > 0xFFFFFFFF: + continue + + mapping[line2] = tableid + + @classmethod + def _parse_route_tables_mapping_from_file(cls, filename, mapping): + try: + with open(filename, "rb") as f: + file_content = f.read() + except Exception: + return + cls._parse_route_tables_mapping(file_content, mapping) + + @classmethod + def get_route_tables_mapping(cls): + if not hasattr(cls, "_cached_rt_tables"): + mapping = {} + cls._parse_route_tables_mapping_from_file( + "/etc/iproute2/rt_tables", mapping + ) + # In iproute2, the directory `/etc/iproute2/rt_tables/rt_tables.d` + # is also iterated when get the mapping between the route table name + # and route table id, + # https://git.kernel.org/pub/scm/network/iproute2/iproute2.git/tree/lib/rt_names.c?id=ade99e208c1843ed3b6eb9d138aa15a6a5eb5219#n391 + try: + fnames = os.listdir("/etc/iproute2/rt_tables.d") + except Exception: + fnames = [] + for f in fnames: + if f.endswith(".conf") and f[0] != ".": + cls._parse_route_tables_mapping_from_file( + "/etc/iproute2/rt_tables.d/" + f, mapping + ) + cls._cached_rt_tables = mapping + return cls._cached_rt_tables diff --git a/tests/ensure_provider_tests.py b/tests/ensure_provider_tests.py index ab8f4d7..e989eed 100755 --- a/tests/ensure_provider_tests.py +++ b/tests/ensure_provider_tests.py @@ -84,6 +84,7 @@ ibution_major_version | int < 9", "comment": "# NetworkManager 1.26.0 added support for match.path setting", }, "playbooks/tests_reapply.yml": {}, + "playbooks/tests_route_table.yml": {}, # team interface is not supported on Fedora "playbooks/tests_team.yml": { EXTRA_RUN_CONDITION: "ansible_distribution != 'Fedora'", diff --git a/tests/playbooks/tests_route_table.yml b/tests/playbooks/tests_route_table.yml new file mode 100644 index 0000000..3c799be --- /dev/null +++ b/tests/playbooks/tests_route_table.yml @@ -0,0 +1,151 @@ +# SPDX-License-Identifier: BSD-3-Clause +--- +- hosts: all + +- name: Test configuring ethernet devices + hosts: all + vars: + type: veth + interface: ethtest0 + + + tasks: + - name: "set type={{ type }} and interface={{ interface }}" + set_fact: + type: "{{ type }}" + interface: "{{ interface }}" + - include_tasks: tasks/show_interfaces.yml + - include_tasks: tasks/manage_test_interface.yml + vars: + state: present + - include_tasks: tasks/assert_device_present.yml + + - name: Configure connection profile and specify the numeric table in + static routes + import_role: + name: linux-system-roles.network + vars: + network_connections: + - name: "{{ interface }}" + interface_name: "{{ interface }}" + state: up + type: ethernet + autoconnect: yes + ip: + dhcp4: no + address: + - 198.51.100.3/26 + route: + - network: 198.51.100.128 + prefix: 26 + gateway: 198.51.100.1 + metric: 2 + table: 30400 + - network: 198.51.100.64 + prefix: 26 + gateway: 198.51.100.6 + metric: 4 + table: 30200 + + - name: Get the routes from the route table 30200 + command: ip route show table 30200 + register: route_table_30200 + ignore_errors: yes + changed_when: false + + - name: Get the routes from the route table 30400 + command: ip route show table 30400 + register: route_table_30400 + ignore_errors: yes + changed_when: false + + - name: Assert that the route table 30200 contains the specified route + assert: + that: + - route_table_30200.stdout is search("198.51.100.64/26 via + 198.51.100.6 dev ethtest0 proto static metric 4") + msg: "the route table 30200 does not exist or does not contain the + specified route" + + + - name: Assert that the route table 30400 contains the specified route + assert: + that: + - route_table_30400.stdout is search("198.51.100.128/26 via + 198.51.100.1 dev ethtest0 proto static metric 2") + msg: "the route table 30400 does not exist or does not contain the + specified route" + + - name: Create a dedicated test file in `/etc/iproute2/rt_tables.d/` and + add a new routing table + lineinfile: + path: /etc/iproute2/rt_tables.d/table.conf + line: "200 custom" + mode: "0644" + create: yes + + - name: Reconfigure connection profile and specify the named table in + static routes + import_role: + name: linux-system-roles.network + vars: + network_connections: + - name: "{{ interface }}" + interface_name: "{{ interface }}" + state: up + type: ethernet + autoconnect: yes + ip: + dhcp4: no + address: + - 198.51.100.3/26 + route: + - network: 198.51.100.128 + prefix: 26 + gateway: 198.51.100.1 + metric: 2 + table: custom + - network: 198.51.100.64 + prefix: 26 + gateway: 198.51.100.6 + metric: 4 + table: custom + + - name: Get the routes from the named route table 'custom' + command: ip route show table custom + register: route_table_custom + ignore_errors: yes + changed_when: false + + - name: Assert that the named route table 'custom' contains the + specified route + assert: + that: + - route_table_custom.stdout is search("198.51.100.128/26 via + 198.51.100.1 dev ethtest0 proto static metric 2") + - route_table_custom.stdout is search("198.51.100.64/26 via + 198.51.100.6 dev ethtest0 proto static metric 4") + msg: "the named route table 'custom' does not exist or does not contain + the specified route" + + - name: Remove the dedicated test file in `/etc/iproute2/rt_tables.d/` + file: + state: absent + path: /etc/iproute2/rt_tables.d/table.conf + +- import_playbook: down_profile.yml + vars: + profile: "{{ interface }}" +# FIXME: assert profile/device down +- import_playbook: remove_profile.yml + vars: + profile: "{{ interface }}" +# FIXME: assert profile away +- name: Remove interfaces + hosts: all + tasks: + - include_tasks: tasks/manage_test_interface.yml + vars: + state: absent + - include_tasks: tasks/assert_device_absent.yml +... diff --git a/tests/tests_route_table_nm.yml b/tests/tests_route_table_nm.yml new file mode 100644 index 0000000..d72970c --- /dev/null +++ b/tests/tests_route_table_nm.yml @@ -0,0 +1,20 @@ +# SPDX-License-Identifier: BSD-3-Clause +# This file was generated by ensure_provider_tests.py +--- +# set network provider and gather facts +- hosts: all + name: Run playbook 'playbooks/tests_route_table.yml' with nm as provider + tasks: + - include_tasks: tasks/el_repo_setup.yml + - name: Set network provider to 'nm' + set_fact: + network_provider: nm + tags: + - always + + +# The test requires or should run with NetworkManager, therefore it cannot run +# on RHEL/CentOS 6 +- import_playbook: playbooks/tests_route_table.yml + when: + - ansible_distribution_major_version != '6' diff --git a/tests/unit/test_network_connections.py b/tests/unit/test_network_connections.py index 85828ea..8d05f16 100644 --- a/tests/unit/test_network_connections.py +++ b/tests/unit/test_network_connections.py @@ -230,6 +230,7 @@ class TestValidator(Python26CompatTestCase): "prefix": int(r.get_prefix()), "gateway": r.get_next_hop(), "metric": int(r.get_metric()), + "table": r.get_attribute("table"), } for r in route_list_new ] @@ -267,17 +268,21 @@ class TestValidator(Python26CompatTestCase): s6.clear_routes() for r in kwargs["nm_route_list_current"][idx]: r = parser.validate(r) - r = NM.IPRoute.new( + rr = NM.IPRoute.new( r["family"], r["network"], r["prefix"], r["gateway"], r["metric"], ) - if r.get_family() == socket.AF_INET: - s4.add_route(r) + if r["table"]: + NM.IPRoute.set_attribute( + rr, "table", Util.GLib().Variant.new_uint32(r["table"]) + ) + if r["family"] == socket.AF_INET: + s4.add_route(rr) else: - s6.add_route(r) + s6.add_route(rr) con_new = nmutil.connection_create( connections, idx, connection_current=con_new ) @@ -1038,6 +1043,7 @@ class TestValidator(Python26CompatTestCase): "prefix": 24, "gateway": None, "metric": -1, + "table": None, } ], }, @@ -1357,6 +1363,7 @@ class TestValidator(Python26CompatTestCase): "prefix": 24, "gateway": None, "metric": -1, + "table": None, } ], }, @@ -1495,6 +1502,7 @@ class TestValidator(Python26CompatTestCase): "prefix": 24, "gateway": None, "metric": -1, + "table": None, } ], }, @@ -1551,6 +1559,7 @@ class TestValidator(Python26CompatTestCase): "prefix": 24, "gateway": None, "metric": -1, + "table": None, } ], }, @@ -2248,6 +2257,7 @@ class TestValidator(Python26CompatTestCase): "prefix": 24, "gateway": None, "metric": 545, + "table": None, }, { "family": socket.AF_INET, @@ -2255,6 +2265,7 @@ class TestValidator(Python26CompatTestCase): "prefix": 30, "gateway": None, "metric": -1, + "table": None, }, ], "dns": [], @@ -2345,6 +2356,7 @@ class TestValidator(Python26CompatTestCase): "prefix": 24, "gateway": None, "metric": 545, + "table": None, }, { "family": socket.AF_INET, @@ -2352,6 +2364,7 @@ class TestValidator(Python26CompatTestCase): "prefix": 30, "gateway": None, "metric": -1, + "table": None, }, { "family": socket.AF_INET6, @@ -2359,6 +2372,7 @@ class TestValidator(Python26CompatTestCase): "prefix": 64, "gateway": None, "metric": -1, + "table": None, }, ], "dns": [], @@ -4081,6 +4095,223 @@ class TestUtils(unittest.TestCase): self.assertEqual(result, test_case[1]) +class TestValidatorRouteTable(Python26CompatTestCase): + def setUp(self): + self.test_connections = [ + { + "name": "eth0", + "type": "ethernet", + "ip": { + "dhcp4": False, + "address": ["198.51.100.3/26"], + "route": [ + { + "network": "198.51.100.128", + "prefix": 26, + "gateway": "198.51.100.1", + "metric": 2, + "table": 30400, + }, + ], + }, + } + ] + self.validator = network_lsr.argument_validator.ArgValidator_ListConnections() + self.rt_parsing = network_lsr.argument_validator.IPRouteUtils() + self.old_getter = ( + network_lsr.argument_validator.IPRouteUtils.get_route_tables_mapping + ) + network_lsr.argument_validator.IPRouteUtils.get_route_tables_mapping = ( + classmethod( + lambda cls: { + "custom": 200, + "eth": 30400, + } + ) + ) + # the connection index is 0 because there is only one connection profile + # defined here + self.connection_index = 0 + + def tearDown(self): + network_lsr.argument_validator.IPRouteUtils.get_route_tables_mapping = ( + self.old_getter + ) + + def test_valid_numeric_route_tables(self): + """ + Test that the value between 1 and 4294967295 are the valid value for numeric + route tables and the value will not be normalized + """ + + self.validator.validate_route_tables( + self.validator.validate(self.test_connections)[0], + self.connection_index, + ) + self.assertEqual( + self.test_connections[0]["ip"]["route"][0]["table"], + 30400, + ) + + self.test_connections[0]["ip"]["route"][0]["table"] = 200 + self.validator.validate_route_tables( + self.validator.validate(self.test_connections)[0], + self.connection_index, + ) + self.assertEqual( + self.test_connections[0]["ip"]["route"][0]["table"], + 200, + ) + + def test_invalid_numeric_route_tables(self): + """ + Test that the value less than 1 or greater than 4294967295 are the invalid + value for numeric route tables + """ + + self.test_connections[0]["ip"]["route"][0]["table"] = 0 + val_min = 1 + val_max = 0xFFFFFFFF + self.assertRaisesRegex( + ValidationError, + "route table value is {0} but cannot be less than {1}".format( + self.test_connections[0]["ip"]["route"][0]["table"], + val_min, + ), + self.validator.validate, + self.test_connections, + ) + + self.test_connections[0]["ip"]["route"][0]["table"] = 4294967296 + self.assertRaisesRegex( + ValidationError, + "route table value is {0} but cannot be greater than {1}".format( + self.test_connections[0]["ip"]["route"][0]["table"], + val_max, + ), + self.validator.validate, + self.test_connections, + ) + + def test_empty_route_table_name(self): + """ + Test that empty string is invalid value for route table name + """ + + self.test_connections[0]["ip"]["route"][0]["table"] = "" + self.assertRaisesRegex( + ValidationError, + "route table name cannot be empty string", + self.validator.validate, + self.test_connections, + ) + + def test_invalid_value_types_for_route_tables(self): + """ + Test that the value types apart from string type and integer type are all + invalid value types for route tables + """ + + self.test_connections[0]["ip"]["route"][0]["table"] = False + self.assertRaisesRegex( + ValidationError, + "route table must be the named or numeric tables but is {0}".format( + self.test_connections[0]["ip"]["route"][0]["table"] + ), + self.validator.validate, + self.test_connections, + ) + + self.test_connections[0]["ip"]["route"][0]["table"] = 2.5 + self.assertRaisesRegex( + ValidationError, + "route table must be the named or numeric tables but is {0}".format( + self.test_connections[0]["ip"]["route"][0]["table"] + ), + self.validator.validate, + self.test_connections, + ) + + def test_invalid_route_table_names(self): + """ + Test that the route table names should not be composed from the characters + which are not contained within the benign set r"^[a-zA-Z0-9_.-]+$" + """ + + self.test_connections[0]["ip"]["route"][0]["table"] = "test*" + self.assertRaisesRegex( + ValidationError, + "route table name contains invalid characters", + self.validator.validate, + self.test_connections, + ) + + self.test_connections[0]["ip"]["route"][0]["table"] = "!!!" + self.assertRaisesRegex( + ValidationError, + "route table name contains invalid characters", + self.validator.validate, + self.test_connections, + ) + + def test_parse_rt_tables(self): + """ + Test that the `IPRouteUtils._parse_route_tables_mapping()` will create the + route tables mapping dictionary when feeding the proper route table file + content + """ + + def parse(file_content): + mapping = {} + network_lsr.argument_validator.IPRouteUtils._parse_route_tables_mapping( + file_content, mapping + ) + return mapping + + self.assertEqual(parse(b""), {}) + self.assertEqual(parse(b"5 x"), {"x": 5}) + self.assertEqual(parse(b" 7 y "), {}) + self.assertEqual(parse(b"5 x\n0x4 y"), {"x": 5, "y": 4}) + self.assertEqual(parse(b"5 x #df\n0x4 y"), {"x": 5, "y": 4}) + self.assertEqual(parse(b"5 x #df\n0x4 y\n7\ty"), {"x": 5, "y": 4}) + self.assertEqual(parse(b"-1 x #df\n0x4 y\n5 x"), {"x": 5, "y": 4}) + + def test_table_found_when_validate_route_tables(self): + """ + Test that the `validate_route_tables()` will find the table id mapping from + `IPRouteUtils.get_route_tables_mapping()`. + """ + + self.test_connections[0]["ip"]["route"][0]["table"] = "custom" + + self.validator.validate_route_tables( + self.test_connections[0], + self.connection_index, + ) + self.assertEqual( + self.test_connections[0]["ip"]["route"][0]["table"], + 200, + ) + + def test_table_not_found_when_validate_route_tables(self): + """ + Test that the validation error is raised when the `validate_route_tables()` cannot + find the table id mapping from `IPRouteUtils.get_route_tables_mapping()`. + """ + + self.test_connections[0]["ip"]["route"][0]["table"] = "test" + self.assertRaisesRegex( + ValidationError, + "cannot find route table {0} in `/etc/iproute2/rt_tables` or " + "`/etc/iproute2/rt_tables.d/`".format( + self.test_connections[0]["ip"]["route"][0]["table"] + ), + self.validator.validate_route_tables, + self.test_connections[0], + self.connection_index, + ) + + class TestSysUtils(unittest.TestCase): def test_link_read_permaddress(self): self.assertEqual(SysUtil._link_read_permaddress("lo"), "00:00:00:00:00:00")