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 <liangwen12year@gmail.com>
This commit is contained in:
Wen Liang 2022-01-05 22:30:20 -05:00 committed by Fernando Fernández Mancera
parent 20667b0860
commit 5eb03fa992
8 changed files with 620 additions and 6 deletions

View file

@ -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.

View file

@ -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
...

View file

@ -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:

View file

@ -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

View file

@ -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'",

View file

@ -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
...

View file

@ -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'

View file

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