summaryrefslogtreecommitdiff
path: root/python
diff options
context:
space:
mode:
authorChristian Breunig <christian@breunig.cc>2026-11-01 18:53:09 +0000
committerChristian Breunig <christian@breunig.cc>2026-11-01 19:58:49 +0100
commitf819e149290ea56583b3a4b1cef3c5f86a01ab37 (patch)
tree99ef8275694751423c9b38c7a1b9f40846cdf8fd /python
parent39f31b58157d89edc077392d436c12a42ab1d726 (diff)
downloadvyos-1x-f819e149290ea56583b3a4b1cef3c5f86a01ab37.tar.gz
vyos-1x-f819e149290ea56583b3a4b1cef3c5f86a01ab37.zip
frrender: T9482: drop a blackhole route FRR would refuse
The default route of a DHCP or PPPoE interface changes with an interface-only commit or a lease event, without a commit of the static routes. If it gets the same prefix and distance as a blackhole or reject route, FRR refuses the reload and every static route added with it is lost. The rendered FRR configuration is now checked for a blackhole or reject route next to any other route of the same prefix and distance, per VRF and table. Such a blackhole or reject route is not rendered, and an error naming it is logged to the journal. Prefixes are compared in their normalized form, as FRR stores them.
Diffstat (limited to 'python')
-rw-r--r--python/vyos/frrender.py80
-rw-r--r--python/vyos/utils/network.py21
2 files changed, 101 insertions, 0 deletions
diff --git a/python/vyos/frrender.py b/python/vyos/frrender.py
index 824342773..275316e4a 100644
--- a/python/vyos/frrender.py
+++ b/python/vyos/frrender.py
@@ -47,6 +47,75 @@ def debug(message):
return
print(message)
+# options of a static route in staticd.frr.j2 which are followed by a value
+static_route_options = ['tag', 'table', 'nexthop-vrf', 'segments', 'source', 'profile']
+
+def drop_conflicting_discard_routes(output: str) -> tuple:
+ """
+ FRR staticd refuses a blackhole or reject route next to another route of
+ the same prefix and distance, and frr-reload.py then loses every static
+ route of that reload. protocols_static.py refuses this for static routes,
+ but the default route of a DHCP or PPPoE interface changes without a
+ commit of the static routes. Drop the blackhole and reject routes of such
+ a prefix from the rendered configuration.
+
+ Returns the configuration and a message for every dropped route.
+ """
+ from vyos.utils.network import normalize_prefix
+
+ lines = output.splitlines()
+ routes = {}
+ vrf = None
+ for index, line in enumerate(lines):
+ tokens = line.split()
+ if len(tokens) == 2 and tokens[0] == 'vrf':
+ vrf = tokens[1]
+ continue
+ if tokens == ['exit-vrf']:
+ vrf = None
+ continue
+ # <afi> route <prefix> <next-hop> [options]
+ if len(tokens) < 4 or tokens[0] not in ['ip', 'ipv6'] or tokens[1] != 'route':
+ continue
+
+ table = None
+ distance = None
+ options = iter(tokens[4:])
+ for token in options:
+ if token in static_route_options:
+ value = next(options, None)
+ if token == 'table':
+ table = value
+ elif token.isdigit() and distance is None:
+ distance = int(token)
+ # FRR uses distance 1 if none is given
+ key = (vrf, tokens[0], table, normalize_prefix(tokens[2]), distance or 1)
+ routes.setdefault(key, []).append((index, tokens[3] in ['blackhole', 'reject']))
+
+ drop = []
+ messages = []
+ for (vrf, _, table, prefix, distance), entries in routes.items():
+ discards = [index for index, discard in entries if discard]
+ if not discards or len(discards) == len(entries):
+ continue
+ context = ''
+ if vrf:
+ context += f' in VRF "{vrf}"'
+ if table:
+ context += f' in table {table}'
+ for index in discards:
+ drop.append(index)
+ messages.append(f'Static route "{lines[index].strip()}"{context} not '
+ f'applied, FRR refuses a blackhole or reject route '
+ f'next to another route of prefix {prefix} with the '
+ f'same distance {distance}')
+
+ if not drop:
+ return output, messages
+ drop = set(drop)
+ output = os.linesep.join(line for index, line in enumerate(lines) if index not in drop)
+ return output, messages
+
frr_config_file: str = '/run/frr/config/vyos.frr.conf'
# Configuration of the last successful reload, for consumers which have no
# cached configuration of their own
@@ -953,6 +1022,17 @@ class FRRender:
# remove any accidentally added empty newline to not confuse FRR
output = os.linesep.join([s for s in output.splitlines() if s])
+ # T9482: the default route of a DHCP or PPPoE interface can collide with
+ # a blackhole or reject route, FRR would refuse the whole reload
+ output, errors = drop_conflicting_discard_routes(output)
+ if errors:
+ from systemd import journal
+ for error in errors:
+ debug(error)
+ # journal fields must be upper case, else they are dropped
+ journal.send(error, PRIORITY=journal.LOG_ERR,
+ SYSLOG_IDENTIFIER='vyos-frrender')
+
if '!!' in output:
raise ConfigError('FRR configuration contains "!!" which is not allowed')
diff --git a/python/vyos/utils/network.py b/python/vyos/utils/network.py
index 42fec00f0..4a4414f56 100644
--- a/python/vyos/utils/network.py
+++ b/python/vyos/utils/network.py
@@ -19,6 +19,8 @@ from json import loads
from socket import AF_INET
from socket import AF_INET6
from socket import AF_UNSPEC
+from socket import inet_ntop
+from socket import inet_pton
from vyos.utils.process import cmdl
def _are_same_ip(one, two):
@@ -994,3 +996,22 @@ def fqdn_resolve(
addresses = [item[4][0] for item in res]
return frozenset(addresses) if addresses else None
+
+def normalize_prefix(prefix: str) -> str:
+ """
+ Return the prefix as FRR stores it: host bits cleared and the address in
+ its canonical notation. A prefix that cannot be parsed is returned as is.
+ inet_pton() is what FRR parses a prefix with, and is several times faster
+ than the ipaddress module.
+ """
+ address, _, length = prefix.partition('/')
+ family, bits = (AF_INET6, 128) if ':' in address else (AF_INET, 32)
+ try:
+ length = int(length)
+ value = int.from_bytes(inet_pton(family, address), 'big')
+ except (OSError, ValueError):
+ return prefix
+ if not 0 <= length <= bits:
+ return prefix
+ value &= ~((1 << (bits - length)) - 1)
+ return f'{inet_ntop(family, value.to_bytes(bits // 8, "big"))}/{length}'