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")