route: Add support for the route-type

When users have BGP routing setups, it is common practice to blackhole
some less-specific routes in order to avoid routing loops, and the BGP
router might insert a more specific route dynamically afterwards.

Examples:

```
interfaces:
  - name: eth1
    type: ethernet
    state: up
    ipv4:
      address:
        - ip: 192.0.2.251
          prefix-length: 24
      dhcp: false
      enabled: true

routes:
  config:
    - destination: 198.51.100.0/24
      metric: 150
      next-hop-address: 192.0.2.1
      next-hop-interface: eth1
      table-id: 254
    - destination: 198.51.200.0/24
      route-type: blackhole
```

Signed-off-by: Wen Liang <liangwen12year@gmail.com>
This commit is contained in:
Wen Liang 2023-11-07 16:31:38 -05:00 committed by Gris Ge
parent af88bb2429
commit f8c57a88fe
13 changed files with 379 additions and 19 deletions
examples
rust/src
lib
lib.rs
nispor
nm
nm_dbus
connection
gen_conf
settings
query_apply
route.rs
python/libnmstate
tests/integration

@ -17,3 +17,5 @@ routes:
next-hop-address: 192.0.2.1
next-hop-interface: eth1
table-id: 254
- destination: 198.51.200.0/24
route-type: blackhole

@ -177,7 +177,7 @@ pub use crate::policy::{
NetworkCaptureRules, NetworkPolicy, NetworkStateTemplate,
};
pub(crate) use crate::route::MergedRoutes;
pub use crate::route::{RouteEntry, RouteState, Routes};
pub use crate::route::{RouteEntry, RouteState, RouteType, Routes};
pub(crate) use crate::route_rule::MergedRouteRules;
pub use crate::route_rule::{
RouteRuleAction, RouteRuleEntry, RouteRuleState, RouteRules,

@ -1,6 +1,6 @@
use log::warn;
use crate::{RouteEntry, Routes};
use crate::{RouteEntry, RouteType, Routes};
const SUPPORTED_ROUTE_SCOPE: [nispor::RouteScope; 2] =
[nispor::RouteScope::Universe, nispor::RouteScope::Link];
@ -26,7 +26,11 @@ const IPV6_EMPTY_NEXT_HOP_ADDRESS: &str = "::";
pub(crate) fn get_routes(running_config_only: bool) -> Routes {
let mut ret = Routes::new();
let mut np_routes: Vec<nispor::Route> = Vec::new();
let route_type = [
nispor::RouteType::BlackHole,
nispor::RouteType::Unreachable,
nispor::RouteType::Prohibit,
];
let protocols = if running_config_only {
SUPPORTED_STATIC_ROUTE_PROTOCOL.as_slice()
} else {
@ -64,6 +68,8 @@ pub(crate) fn get_routes(running_config_only: bool) -> Routes {
for route in flat_multipath_route(np_route) {
running_routes.push(route);
}
} else if route_type.contains(&np_route.route_type) {
running_routes.push(np_routetype_to_nmstate(np_route));
} else if np_route.oif.is_some() {
running_routes.push(np_route_to_nmstate(np_route));
}
@ -80,6 +86,8 @@ pub(crate) fn get_routes(running_config_only: bool) -> Routes {
for route in flat_multipath_route(np_route) {
config_routes.push(route);
}
} else if route_type.contains(&np_route.route_type) {
config_routes.push(np_routetype_to_nmstate(np_route));
} else if np_route.oif.is_some() {
config_routes.push(np_route_to_nmstate(np_route));
}
@ -88,6 +96,50 @@ pub(crate) fn get_routes(running_config_only: bool) -> Routes {
ret
}
fn np_routetype_to_nmstate(np_route: &nispor::Route) -> RouteEntry {
let destination = match &np_route.dst {
Some(dst) => Some(dst.to_string()),
None => match np_route.address_family {
nispor::AddressFamily::IPv4 => {
Some(IPV4_DEFAULT_GATEWAY.to_string())
}
nispor::AddressFamily::IPv6 => {
Some(IPV6_DEFAULT_GATEWAY.to_string())
}
_ => {
warn!(
"Route {:?} is holding unknown IP family {:?}",
np_route, np_route.address_family
);
None
}
},
};
let mut route_entry = RouteEntry::new();
route_entry.destination = destination;
if np_route.address_family == nispor::AddressFamily::IPv6 {
route_entry.next_hop_iface = np_route.oif.as_ref().cloned();
}
route_entry.metric = np_route.metric.map(i64::from);
route_entry.table_id = Some(np_route.table);
match np_route.route_type {
nispor::RouteType::BlackHole => {
route_entry.route_type = Some(RouteType::Blackhole)
}
nispor::RouteType::Unreachable => {
route_entry.route_type = Some(RouteType::Unreachable)
}
nispor::RouteType::Prohibit => {
route_entry.route_type = Some(RouteType::Prohibit)
}
_ => {
log::debug!("Got unsupported route {:?}", np_route);
}
}
route_entry
}
fn np_route_to_nmstate(np_route: &nispor::Route) -> RouteEntry {
let destination = match &np_route.dst {
Some(dst) => Some(dst.to_string()),

@ -16,6 +16,7 @@ pub struct NmIpRoute {
pub table: Option<u32>,
pub metric: Option<u32>,
pub weight: Option<u32>,
pub route_type: Option<String>,
_other: DbusDictionary,
}
@ -35,6 +36,7 @@ impl TryFrom<DbusDictionary> for NmIpRoute {
table: _from_map!(v, "table", u32::try_from)?,
metric: _from_map!(v, "metric", u32::try_from)?,
weight,
route_type: _from_map!(v, "type", String::try_from)?,
_other: v,
})
}
@ -82,7 +84,12 @@ impl NmIpRoute {
zvariant::Value::new(zvariant::Value::new(v)),
)?;
}
if let Some(v) = &self.route_type {
ret.append(
zvariant::Value::new("type"),
zvariant::Value::new(zvariant::Value::new(v)),
)?;
}
for (key, value) in self._other.iter() {
ret.append(
zvariant::Value::new(key.as_str()),

@ -26,6 +26,9 @@ impl NmIpRoute {
if let Some(weight) = self.weight {
write!(opt_string, ",weight={}", weight).ok();
}
if let Some(route_type) = self.route_type.as_ref() {
write!(opt_string, ",type={}", route_type).ok();
}
ret.insert("options".to_string(), opt_string);
}
ret

@ -4,7 +4,9 @@ use std::convert::TryFrom;
use super::super::nm_dbus::NmIpRoute;
use crate::{ip::is_ipv6_addr, InterfaceIpAddr, NmstateError, RouteEntry};
use crate::{
ip::is_ipv6_addr, InterfaceIpAddr, NmstateError, RouteEntry, RouteType,
};
pub(crate) fn gen_nm_ip_routes(
routes: &[RouteEntry],
@ -35,6 +37,12 @@ pub(crate) fn gen_nm_ip_routes(
if let Some(weight) = route.weight {
nm_route.weight = Some(weight as u32);
}
nm_route.route_type = match route.route_type {
Some(RouteType::Blackhole) => Some("blackhole".to_string()),
Some(RouteType::Prohibit) => Some("prohibit".to_string()),
Some(RouteType::Unreachable) => Some("unreachable".to_string()),
None => None,
};
ret.push(nm_route);
}
Ok(ret)

@ -44,7 +44,9 @@ impl MergedRoutes {
if let Some(cur_rts) = current.config.as_ref() {
for cur_rt in cur_rts {
if let Some(via) = cur_rt.next_hop_iface.as_ref() {
if ignored_ifaces.contains(&via.as_str()) {
if ignored_ifaces.contains(&via.as_str())
&& cur_rt.route_type.is_none()
{
continue;
}
}
@ -78,6 +80,24 @@ impl MergedRoutes {
),
));
}
} else if rt.route_type.is_some() {
// In nispor, the IPv4 route with route type `Blackhole`,
// `Unreachable`, `Prohibit` does not have the route oif
// setting.
let mut route_type_rt = rt.clone();
if !route_type_rt.is_ipv6() {
route_type_rt.next_hop_iface = None;
}
if !cur_routes
.as_slice()
.iter()
.any(|cur_rt| route_type_rt.is_match(cur_rt))
{
return Err(NmstateError::new(
ErrorKind::VerificationError,
format!("Desired route {rt} not found after apply"),
));
}
} else if !cur_routes
.as_slice()
.iter()

@ -11,6 +11,9 @@ use crate::{
ErrorKind, InterfaceType, MergedInterfaces, NmstateError,
};
const DEFAULT_TABLE_ID: u32 = 254; // main route table ID
const LOOPBACK_IFACE_NAME: &str = "lo";
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[non_exhaustive]
#[serde(deny_unknown_fields)]
@ -64,17 +67,45 @@ impl Routes {
}
pub fn validate(&self) -> Result<(), NmstateError> {
// All desire non-absent route should have next hop interface
// All desire non-absent route should have next hop interface except
// for route with route type `Blackhole`, `Unreachable`, `Prohibit`.
if let Some(config_routes) = self.config.as_ref() {
for route in config_routes.iter() {
if !route.is_absent() && route.next_hop_iface.is_none() {
return Err(NmstateError::new(
ErrorKind::NotImplementedError,
format!(
"Route with empty next hop interface \
let no_nexthop_route_type = [
RouteType::Blackhole,
RouteType::Unreachable,
RouteType::Prohibit,
];
if !route.is_absent() {
if route.route_type.is_some()
&& no_nexthop_route_type
.contains(&route.route_type.unwrap())
&& (route.next_hop_iface.is_some()
&& route.next_hop_iface
!= Some(LOOPBACK_IFACE_NAME.to_string())
|| route.next_hop_addr.is_some())
{
return Err(NmstateError::new(
ErrorKind::InvalidArgument,
format!(
"A {:?} Route cannot have a next \
hop : {route:?}",
route.route_type.unwrap()
),
));
} else if route.next_hop_iface.is_none()
&& (route.route_type.is_none()
|| !no_nexthop_route_type
.contains(&route.route_type.unwrap()))
{
return Err(NmstateError::new(
ErrorKind::NotImplementedError,
format!(
"Route with empty next hop interface \
is not supported: {route:?}"
),
));
),
));
}
}
if let Some(dst) = route.destination.as_deref() {
validate_route_dst(dst)?;
@ -118,7 +149,8 @@ pub struct RouteEntry {
)]
/// Route next hop interface name.
/// Serialize and deserialize to/from `next-hop-interface`.
/// Mandatory for every non-absent routes.
/// Mandatory for every non-absent routes except for route with
/// route type `Blackhole`, `Unreachable`, `Prohibit`.
pub next_hop_iface: Option<String>,
#[serde(
skip_serializing_if = "Option::is_none",
@ -154,6 +186,48 @@ pub struct RouteEntry {
deserialize_with = "crate::deserializer::option_u16_or_string"
)]
pub weight: Option<u16>,
/// Route type
/// Serialize and deserialize to/from `route-type`.
#[serde(skip_serializing_if = "Option::is_none")]
pub route_type: Option<RouteType>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
#[serde(deny_unknown_fields)]
pub enum RouteType {
Blackhole,
Unreachable,
Prohibit,
}
impl std::fmt::Display for RouteType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
match self {
Self::Blackhole => "blackhole",
Self::Unreachable => "unreachable",
Self::Prohibit => "prohibit",
}
)
}
}
const RTN_BLACKHOLE: u8 = 6;
const RTN_UNREACHABLE: u8 = 7;
const RTN_PROHIBIT: u8 = 8;
impl From<RouteType> for u8 {
fn from(v: RouteType) -> u8 {
match v {
RouteType::Blackhole => RTN_BLACKHOLE,
RouteType::Unreachable => RTN_UNREACHABLE,
RouteType::Prohibit => RTN_PROHIBIT,
}
}
}
impl RouteEntry {
@ -195,6 +269,9 @@ impl RouteEntry {
if self.weight.is_some() && self.weight != other.weight {
return false;
}
if self.route_type.is_some() && self.route_type != other.route_type {
return false;
}
true
}
@ -208,8 +285,10 @@ impl RouteEntry {
.as_ref()
.map(|d| is_ipv6_addr(d.as_str()))
.unwrap_or_default(),
self.table_id.unwrap_or(RouteEntry::USE_DEFAULT_ROUTE_TABLE),
self.next_hop_iface.as_deref().unwrap_or(""),
self.table_id.unwrap_or(DEFAULT_TABLE_ID),
self.next_hop_iface
.as_deref()
.unwrap_or(LOOPBACK_IFACE_NAME),
self.destination.as_deref().unwrap_or(""),
self.next_hop_addr.as_deref().unwrap_or(""),
self.weight.unwrap_or_default(),
@ -411,6 +490,8 @@ impl MergedRoutes {
));
}
changed_ifaces.insert(via.as_str());
} else if rt.route_type.is_some() {
changed_ifaces.insert(LOOPBACK_IFACE_NAME);
}
}
@ -423,6 +504,8 @@ impl MergedRoutes {
if absent_rt.is_match(rt) {
if let Some(via) = rt.next_hop_iface.as_ref() {
changed_ifaces.insert(via.as_str());
} else {
changed_ifaces.insert(LOOPBACK_IFACE_NAME);
}
}
}
@ -479,6 +562,13 @@ impl MergedRoutes {
Entry::Vacant(v) => v.insert(Vec::new()),
};
rts.push(rt);
} else if rt.route_type.is_some() {
let rts: &mut Vec<RouteEntry> =
match indexed.entry(LOOPBACK_IFACE_NAME.to_string()) {
Entry::Occupied(o) => o.into_mut(),
Entry::Vacant(v) => v.insert(Vec::new()),
};
rts.push(rt);
}
}

@ -40,6 +40,10 @@ class Route:
NEXT_HOP_ADDRESS = "next-hop-address"
METRIC = "metric"
WEIGHT = "weight"
ROUTETYPE = "route-type"
ROUTETYPE_BLACKHOLE = "blackhole"
ROUTETYPE_UNREACHABLE = "unreachable"
ROUTETYPE_PROHIBIT = "prohibit"
USE_DEFAULT_METRIC = -1
USE_DEFAULT_ROUTE_TABLE = 0

@ -115,6 +115,11 @@ def test_dns_edit(eth1_up):
@pytest.mark.tier1
@pytest.mark.skipif(
nm_minor_version() < 42,
reason="Loopback is only support on NM 1.42+, and blackhole type route "
"is stored in loopback",
)
def test_add_remove_routes(eth1_up):
"""
Test adding a strict route and removing all routes next hop to eth1.

@ -140,6 +140,175 @@ def test_add_static_route_without_next_hop_address(eth1_up):
assert_routes(routes, cur_state)
@pytest.mark.tier1
@pytest.mark.skipif(
nm_minor_version() < 42,
reason="Loopback is only support on NM 1.42+, and blackhole type route "
"is stored in loopback",
)
def test_add_static_route_with_route_type(eth1_up):
route = [
{
Route.DESTINATION: IPV4_TEST_NET1,
Route.NEXT_HOP_INTERFACE: "lo",
Route.ROUTETYPE: Route.ROUTETYPE_BLACKHOLE,
},
{
Route.DESTINATION: IPV6_TEST_NET1,
Route.ROUTETYPE: Route.ROUTETYPE_UNREACHABLE,
},
{
Route.DESTINATION: "198.51.100.0/24",
Route.ROUTETYPE: Route.ROUTETYPE_PROHIBIT,
},
]
libnmstate.apply(
{
Interface.KEY: [ETH1_INTERFACE_STATE],
Route.KEY: {Route.CONFIG: route},
}
)
routes_output4 = _get_routes_from_iproute(4, "main")
routes_output6 = _get_routes_from_iproute(6, "main")
assert IPV4_TEST_NET1 in routes_output4
assert Route.ROUTETYPE_BLACKHOLE in routes_output4
assert "198.51.100.0/24" in routes_output4
assert Route.ROUTETYPE_PROHIBIT in routes_output4
assert IPV6_TEST_NET1 in routes_output6
assert Route.ROUTETYPE_UNREACHABLE in routes_output6
@pytest.mark.tier1
@pytest.mark.skipif(
nm_minor_version() < 42,
reason="Loopback is only support on NM 1.42+, and blackhole type route "
"is stored in loopback",
)
def test_add_static_route_and_apply_route_absent(eth1_up):
routes = [
{
Route.DESTINATION: "198.51.100.0/24",
Route.ROUTETYPE: Route.ROUTETYPE_BLACKHOLE,
},
{
Route.DESTINATION: IPV4_TEST_NET1,
Route.NEXT_HOP_INTERFACE: "eth1",
Route.NEXT_HOP_ADDRESS: "192.0.2.1",
},
{
Route.DESTINATION: IPV6_TEST_NET1,
Route.NEXT_HOP_INTERFACE: "eth1",
Route.NEXT_HOP_ADDRESS: "2001:db8:1::b",
},
]
libnmstate.apply(
{
Interface.KEY: [ETH1_INTERFACE_STATE],
Route.KEY: {Route.CONFIG: routes},
}
)
absent_route = routes[0]
absent_route[Route.STATE] = Route.STATE_ABSENT
libnmstate.apply(
{
Interface.KEY: [ETH1_INTERFACE_STATE],
Route.KEY: {Route.CONFIG: [absent_route]},
}
)
remaining_routes = routes[1:]
cur_state = libnmstate.show()
assert_routes(remaining_routes, cur_state)
@pytest.mark.tier1
@pytest.mark.skipif(
nm_minor_version() < 42,
reason="Loopback is only support on NM 1.42+, and blackhole type route "
"is stored in loopback",
)
def test_add_static_Ipv4_route_with_route_type(eth1_up):
routes = [
{
Route.DESTINATION: "198.51.100.0/24",
Route.NEXT_HOP_INTERFACE: "lo",
Route.ROUTETYPE: Route.ROUTETYPE_BLACKHOLE,
},
]
libnmstate.apply(
{
Interface.KEY: [ETH1_INTERFACE_STATE],
Route.KEY: {Route.CONFIG: routes},
}
)
cur_state = libnmstate.show()
current_routes = cur_state[Route.KEY][Route.CONFIG]
for route in current_routes:
if route.get(Route.ROUTETYPE, None) == Route.ROUTETYPE_BLACKHOLE:
assert route.get(Route.NEXT_HOP_INTERFACE, None) is None
@pytest.mark.tier1
@pytest.mark.skipif(
nm_minor_version() < 42,
reason="Loopback is only support on NM 1.42+, and blackhole type route "
"is stored in loopback",
)
def test_route_type_with_next_hop_interface(eth1_up):
route = [
{
Route.DESTINATION: IPV4_TEST_NET1,
Route.NEXT_HOP_INTERFACE: "eth1",
Route.ROUTETYPE: Route.ROUTETYPE_BLACKHOLE,
},
]
state = {
Interface.KEY: [ETH1_INTERFACE_STATE],
Route.KEY: {Route.CONFIG: route},
}
with pytest.raises(NmstateValueError):
libnmstate.apply(state)
@pytest.mark.tier1
@pytest.mark.skipif(
nm_minor_version() < 42,
reason="Loopback is only support on NM 1.42+, and blackhole type route "
"is stored in loopback",
)
def test_apply_route_with_route_type_multiple_times(eth1_up):
routes = [
{
Route.DESTINATION: "198.51.100.0/24",
Route.ROUTETYPE: Route.ROUTETYPE_BLACKHOLE,
},
{
Route.DESTINATION: IPV6_TEST_NET1,
Route.ROUTETYPE: Route.ROUTETYPE_UNREACHABLE,
},
]
libnmstate.apply(
{
Interface.KEY: [ETH1_INTERFACE_STATE],
Route.KEY: {Route.CONFIG: routes},
}
)
libnmstate.apply(
{
Interface.KEY: [ETH1_INTERFACE_STATE],
Route.KEY: {Route.CONFIG: routes},
}
)
_, routes_out_v4, _ = cmdlib.exec_cmd(
"nmcli -g ipv4.routes con show lo".split(), check=True
)
_, routes_out_v6, _ = cmdlib.exec_cmd(
"nmcli -g ipv6.routes con show lo".split(), check=True
)
assert routes_out_v4.count("blackhole") == 1
assert routes_out_v6.count("unreachable") == 1
@pytest.mark.tier1
def test_add_gateway(eth1_up):
routes = [_get_ipv4_gateways()[0], _get_ipv6_test_routes()[0]]

@ -141,7 +141,7 @@ def assert_no_config_route_to_iface(iface_name):
assert not any(
route
for route in current_state[Route.KEY][Route.CONFIG]
if route[Route.NEXT_HOP_INTERFACE] == iface_name
if route.get(Route.NEXT_HOP_INTERFACE, None) == iface_name
)

@ -30,7 +30,7 @@ def assert_routes(routes, state, nic="eth1"):
routes.sort(key=_route_sort_key)
config_routes = []
for config_route in state[Route.KEY][Route.CONFIG]:
if config_route[Route.NEXT_HOP_INTERFACE] == nic:
if config_route.get(Route.NEXT_HOP_INTERFACE, None) == nic:
config_routes.append(config_route)
# The kernel routes contains more route entries than desired config