lib.frr
FRR / OSPF / BGP / RIP grading helpers.
Every get_* function registers one command through grade.test() -- so it
follows the two-pass contract of Grade.grade() (placeholder result on the
registration pass, real output afterwards) -- and hands the text to a pure
parse_* function. The parsers are unit-tested on captured FRR 9 output in
tests/test_frr.py. The OSPF and RIP helpers parse the text output of vtysh
(show ip rip and show ip rip status have no json form), the BGP helpers its
json output (show bgp ipv4 unicast ... json). On the placeholder output
every parser returns its empty shape ({}/[]), never an exception. What a
RIP router really announces on a link is read by the passive probe of lib/rip.py.
Pass allow_error=True when the router may not run OSPF/BGP/RIP yet (vtysh
then exits non-zero and the error would otherwise be recorded in the archive).
1"""FRR / OSPF / BGP / RIP grading helpers. 2 3Every ``get_*`` function registers one command through ``grade.test()`` -- so it 4follows the two-pass contract of ``Grade.grade()`` (placeholder result on the 5registration pass, real output afterwards) -- and hands the text to a pure 6``parse_*`` function. The parsers are unit-tested on captured FRR 9 output in 7``tests/test_frr.py``. The OSPF and RIP helpers parse the text output of ``vtysh`` 8(``show ip rip`` and ``show ip rip status`` have no json form), the BGP helpers its 9``json`` output (``show bgp ipv4 unicast ... json``). On the placeholder output 10every parser returns its empty shape (``{}``/``[]``), never an exception. What a 11RIP router really announces on a link is read by the passive probe of ``lib/rip.py``. 12 13Pass ``allow_error=True`` when the router may not run OSPF/BGP/RIP yet (``vtysh`` 14then exits non-zero and the error would otherwise be recorded in the archive). 15""" 16import json 17import re 18from ipaddress import IPv4Address 19from typing import Any, Dict, List, Optional 20 21from SRE.lib_sre import Grade0 22 23_IPV4_RE = re.compile(r'^\d{1,3}(?:\.\d{1,3}){3}$') 24 25#: kernel route protocol values that mean "installed by ospfd" (name from 26#: /etc/iproute2/rt_protos.d/frr.conf, or the raw number when that file is missing) 27OSPF_ROUTE_PROTOCOLS = ('ospf', '188') 28#: kernel route protocol values that mean "installed by bgpd" 29BGP_ROUTE_PROTOCOLS = ('bgp', '186') 30#: kernel route protocol values that mean "installed by ripd" / "by ripngd" 31RIP_ROUTE_PROTOCOLS = ('rip', '189') 32RIPNG_ROUTE_PROTOCOLS = ('ripng', '190') 33 34 35def _test(grade: Grade0, machine_name: str, command: str, step: int, allow_error: bool): 36 if allow_error: 37 return grade.test(machine_name, command, step=step, allow_error=True) 38 return grade.test(machine_name, command, step=step) 39 40 41def normalize_area(area: str) -> str: 42 """Return an OSPF area id in dotted form: ``'1'`` -> ``'0.0.0.1'``, ``'0.0.0.1'`` unchanged.""" 43 area = str(area).strip() 44 if area.isdigit(): 45 return str(IPv4Address(int(area))) 46 return area 47 48 49def is_ospf_protocol(protocol) -> bool: 50 """True when a kernel route ``protocol`` field (``ip -j route``) denotes an OSPF route.""" 51 return str(protocol) in OSPF_ROUTE_PROTOCOLS 52 53 54def is_bgp_protocol(protocol) -> bool: 55 """True when a kernel route ``protocol`` field (``ip -j route``) denotes a BGP route.""" 56 return str(protocol) in BGP_ROUTE_PROTOCOLS 57 58 59def is_rip_protocol(protocol) -> bool: 60 """True when a kernel route ``protocol`` field (``ip -j route``) denotes a RIP route.""" 61 return str(protocol) in RIP_ROUTE_PROTOCOLS 62 63 64def is_ripng_protocol(protocol) -> bool: 65 """True when a kernel route ``protocol`` field (``ip -j -6 route``) denotes a RIPng route.""" 66 return str(protocol) in RIPNG_ROUTE_PROTOCOLS 67 68 69# --------------------------------------------------------------------------- 70# show ip ospf interface 71# --------------------------------------------------------------------------- 72 73def parse_ospf_interfaces(output: str) -> Dict[str, Dict[str, Any]]: 74 """Parse the text of ``show ip ospf interface`` (see :func:`get_ospf_interfaces`).""" 75 result: Dict[str, Dict[str, Any]] = {} 76 current: Dict[str, Any] | None = None 77 78 for line in output.splitlines(): 79 # Interface header: "ethX is up" 80 m = re.match(r'^(\S+) is (up|down)', line) 81 if m: 82 current = { 83 'state': m.group(2), 84 'ospf_enabled': True, 85 'mtu': None, 'bandwidth': None, 86 'internet_address': None, 'broadcast': None, 'area': None, 87 'router_id': None, 'network_type': None, 'cost': None, 88 'ospf_state': None, 'priority': None, 'passive': False, 89 'hello_interval': None, 'dead_interval': None, 'retransmit_interval': None, 90 'neighbor_count': None, 'adjacent_neighbor_count': None, 91 'dr_id': None, 'dr_address': None, 92 'bdr_id': None, 'bdr_address': None, 93 } 94 result[m.group(1)] = current 95 continue 96 97 if current is None: 98 continue 99 100 if 'OSPF not enabled on this interface' in line: 101 current['ospf_enabled'] = False 102 continue 103 104 # ifindex 13, MTU 1500 bytes, BW 10000 Mbit <UP,LOWER_UP,BROADCAST,RUNNING,MULTICAST> 105 m = re.search(r'MTU (\d+) bytes,\s*BW (\d+) Mbit', line) 106 if m: 107 current['mtu'] = int(m.group(1)) 108 current['bandwidth'] = int(m.group(2)) 109 continue 110 111 # Internet Address 10.150.180.224/28, Broadcast 10.150.180.239, Area 0.0.0.0 112 m = re.search(r'Internet Address (\S+?),\s*Broadcast (\S+?),\s*Area (\S+)', line) 113 if m: 114 current['internet_address'] = m.group(1) 115 current['broadcast'] = m.group(2) 116 current['area'] = m.group(3) 117 continue 118 119 # Internet Address 10.0.0.1/30, Area 0.0.0.0 (point-to-point, no broadcast) 120 m = re.search(r'Internet Address (\S+?),\s*Area (\S+)', line) 121 if m: 122 current['internet_address'] = m.group(1) 123 current['area'] = m.group(2) 124 continue 125 126 # Router ID 10.150.180.224, Network Type BROADCAST, Cost: 10 127 m = re.search(r'Router ID (\S+?),\s*Network Type (\S+?),\s*Cost:\s*(\d+)', line) 128 if m: 129 current['router_id'] = m.group(1) 130 current['network_type'] = m.group(2) 131 current['cost'] = int(m.group(3)) 132 continue 133 134 # Transmit Delay is 1 sec, State Backup, Priority 1 135 m = re.search(r'State (\S+?),\s*Priority (\d+)', line) 136 if m: 137 current['ospf_state'] = m.group(1).rstrip(',') 138 current['priority'] = int(m.group(2)) 139 continue 140 141 # Designated Router (ID) 172.23.52.228 Interface Address 10.150.180.230/28 142 m = re.search(r'Designated Router \(ID\) (\S+)\s+Interface Address (\S+)', line) 143 if m and 'Backup' not in line: 144 current['dr_id'] = m.group(1) 145 current['dr_address'] = m.group(2) 146 continue 147 148 # Backup Designated Router (ID) 10.150.180.224, Interface Address 10.150.180.224 149 m = re.search(r'Backup Designated Router \(ID\) (\S+?),\s*Interface Address (\S+)', line) 150 if m: 151 current['bdr_id'] = m.group(1) 152 current['bdr_address'] = m.group(2) 153 continue 154 155 # Timer intervals configured, Hello 10s, Dead 40s, Wait 40s, Retransmit 5 156 m = re.search(r'Hello (\d+)s,\s*Dead (\d+)s,.*Retransmit (\d+)', line) 157 if m: 158 current['hello_interval'] = int(m.group(1)) 159 current['dead_interval'] = int(m.group(2)) 160 current['retransmit_interval'] = int(m.group(3)) 161 continue 162 163 # No Hellos (Passive interface) 164 if 'No Hellos (Passive interface)' in line: 165 current['passive'] = True 166 continue 167 168 # Neighbor Count is 1, Adjacent neighbor count is 1 169 m = re.search(r'Neighbor Count is (\d+),\s*Adjacent neighbor count is (\d+)', line) 170 if m: 171 current['neighbor_count'] = int(m.group(1)) 172 current['adjacent_neighbor_count'] = int(m.group(2)) 173 continue 174 175 return result 176 177 178def get_ospf_interfaces(grade: Grade0, machine_name: str, step: int = 1, 179 allow_error: bool = False) -> Dict[str, Dict[str, Any]]: 180 """Run 'vtysh -c "show ip ospf interface"' and parse the output. 181 182 Returns a dict mapping interface name -> dict of parsed fields: 183 state : 'up' | 'down' 184 ospf_enabled : bool (False for "OSPF not enabled on this interface" entries) 185 mtu : int 186 bandwidth : int (Mbit, the "BW" field: interface speed or `bandwidth` setting) 187 internet_address : str e.g. '10.150.180.224/28' 188 broadcast : str e.g. '10.150.180.239' 189 area : str e.g. '0.0.0.0' 190 router_id : str 191 network_type : str e.g. 'BROADCAST' 192 cost : int 193 ospf_state : str e.g. 'Backup', 'DR', 'DROther' 194 priority : int 195 passive : bool ("No Hellos (Passive interface)") 196 hello_interval : int (seconds) 197 dead_interval : int (seconds) 198 retransmit_interval : int (seconds) 199 neighbor_count : int 200 adjacent_neighbor_count : int 201 dr_id : str | None 202 dr_address : str | None 203 bdr_id : str | None 204 bdr_address : str | None 205 206 Returns {} on error or empty output. 207 """ 208 output, code = _test(grade, machine_name, 'vtysh -c "show ip ospf interface"', step, allow_error) 209 if code != 0 or not output: 210 return {} 211 return parse_ospf_interfaces(output) 212 213 214# --------------------------------------------------------------------------- 215# show ip ospf neighbor 216# --------------------------------------------------------------------------- 217 218def parse_ospf_neighbors(output: str) -> List[Dict[str, Any]]: 219 """Parse the text of ``show ip ospf neighbor``. 220 221 Data rows look like (the "Up Time" column exists in FRR >= 7.5 only):: 222 223 Neighbor ID Pri State Up Time Dead Time Address Interface RXmtL RqstL DBsmL 224 10.0.0.2 1 Full/DR 2m34s 36.245s 10.0.12.2 eth0:10.0.12.1 0 0 0 225 226 Rows are parsed from the end (three counters, interface, address, dead time) so 227 both layouts work. Non-data lines are skipped. 228 """ 229 result: List[Dict[str, Any]] = [] 230 for line in output.splitlines(): 231 fields = line.split() 232 if len(fields) < 9 or not _IPV4_RE.match(fields[0]): 233 continue 234 state, _, role = fields[2].partition('/') 235 interface, _, local_address = fields[-4].partition(':') 236 try: 237 priority: Optional[int] = int(fields[1]) 238 except ValueError: 239 priority = None 240 result.append({ 241 'neighbor_id': fields[0], 242 'priority': priority, 243 'state': state, 244 'role': role or None, 245 'up_time': fields[3] if len(fields) >= 10 else None, 246 'dead_time': fields[-6], 247 'address': fields[-5], 248 'interface': interface, 249 'local_address': local_address or None, 250 }) 251 return result 252 253 254def get_ospf_neighbors(grade: Grade0, machine_name: str, step: int = 1, 255 allow_error: bool = False) -> List[Dict[str, Any]]: 256 """Run 'vtysh -c "show ip ospf neighbor"' and return one dict per neighbour: 257 ``neighbor_id, priority, state ('Full', '2-Way', ...), role ('DR', 'Backup', 258 'DROther', '-'), up_time, dead_time, address, interface, local_address``. 259 Returns [] on error or empty output.""" 260 output, code = _test(grade, machine_name, 'vtysh -c "show ip ospf neighbor"', step, allow_error) 261 if code != 0 or not output: 262 return [] 263 return parse_ospf_neighbors(output) 264 265 266def find_neighbor(neighbors: List[Dict[str, Any]], neighbor_id: str = None, interface: str = None, 267 address: str = None) -> Optional[Dict[str, Any]]: 268 """Return the first neighbour of :func:`get_ospf_neighbors` matching every given criterion.""" 269 for n in neighbors: 270 if neighbor_id is not None and n['neighbor_id'] != str(neighbor_id): 271 continue 272 if interface is not None and n['interface'] != interface: 273 continue 274 if address is not None and n['address'] != str(address): 275 continue 276 return n 277 return None 278 279 280def neighbor_is_full(neighbors: List[Dict[str, Any]], neighbor_id: str = None, interface: str = None, 281 address: str = None) -> bool: 282 """True when a neighbour matching the criteria exists and is in state Full.""" 283 n = find_neighbor(neighbors, neighbor_id=neighbor_id, interface=interface, address=address) 284 return n is not None and n['state'] == 'Full' 285 286 287# --------------------------------------------------------------------------- 288# show ip ospf 289# --------------------------------------------------------------------------- 290 291def parse_ospf_info(output: str) -> Dict[str, Any]: 292 """Parse the text of ``show ip ospf``. 293 294 Returns ``{}`` when no router id is found (OSPF not running), else:: 295 296 {'router_id': '10.0.0.4', 'abr': True, 'asbr': False, 297 'areas': {'0.0.0.0': {'type': 'backbone', 'no_summary': False, 'interfaces': 3, 298 'active_interfaces': 3, 'full_neighbors': 2, 299 'authentication': 'no'}, ...}} 300 301 ``type`` is ``'backbone'``, ``'stub'``, ``'nssa'`` or ``'normal'``; 302 ``authentication`` is ``'no'``, ``'simple password'`` or ``'message digest'``. 303 """ 304 info: Dict[str, Any] = {'router_id': None, 'abr': False, 'asbr': False, 'areas': {}} 305 current: Dict[str, Any] | None = None 306 307 for line in output.splitlines(): 308 m = re.search(r'OSPF Routing Process, Router ID:\s*(\S+)', line) 309 if m: 310 info['router_id'] = m.group(1) 311 continue 312 if 'This router is an ABR' in line: 313 info['abr'] = True 314 continue 315 if 'This router is an ASBR' in line: 316 info['asbr'] = True 317 continue 318 m = re.match(r'^\s*Area ID:\s*(\S+)(?:\s*\((.*?)\))?', line) 319 if m: 320 desc = (m.group(2) or '').strip() 321 low = desc.lower() 322 if low.startswith('backbone'): 323 area_type = 'backbone' 324 elif low.startswith('stub'): 325 area_type = 'stub' 326 elif low.startswith('nssa'): 327 area_type = 'nssa' 328 else: 329 area_type = 'normal' 330 current = { 331 'type': area_type, 332 'no_summary': 'no summary' in low, 333 'interfaces': None, 'active_interfaces': None, 334 'full_neighbors': None, 'authentication': None, 335 } 336 info['areas'][m.group(1)] = current 337 continue 338 if current is None: 339 continue 340 m = re.search(r'Number of interfaces in this area:\s*Total:\s*(\d+),\s*Active:\s*(\d+)', line) 341 if m: 342 current['interfaces'] = int(m.group(1)) 343 current['active_interfaces'] = int(m.group(2)) 344 continue 345 m = re.search(r'Number of fully adjacent neighbors in this area:\s*(\d+)', line) 346 if m: 347 current['full_neighbors'] = int(m.group(1)) 348 continue 349 m = re.search(r'Area has (.+?) authentication', line) 350 if m: 351 current['authentication'] = m.group(1).strip() 352 continue 353 354 if info['router_id'] is None: 355 return {} 356 return info 357 358 359def get_ospf_info(grade: Grade0, machine_name: str, step: int = 1, allow_error: bool = False) -> Dict[str, Any]: 360 """Run 'vtysh -c "show ip ospf"' and return the router-level state (see :func:`parse_ospf_info`). 361 Returns {} when OSPF is not running.""" 362 output, code = _test(grade, machine_name, 'vtysh -c "show ip ospf"', step, allow_error) 363 if code != 0 or not output: 364 return {} 365 return parse_ospf_info(output) 366 367 368def router_id_map(grade: Grade0, machine_names, step: int = 1, allow_error: bool = True) -> Dict[str, str]: 369 """Return ``{router_id: machine_name}`` for every machine whose OSPF process answers.""" 370 result: Dict[str, str] = {} 371 for m in machine_names: 372 rid = get_ospf_info(grade, m, step=step, allow_error=allow_error).get('router_id') 373 if rid: 374 result[rid] = m 375 return result 376 377 378# --------------------------------------------------------------------------- 379# show ip ospf route 380# --------------------------------------------------------------------------- 381 382# "N IA 10.0.5.0/24 [30] area: 0.0.0.0", "D IA 10.37.4.0/22 Discard entry", 383# "N E2 203.0.113.0/24 [10/20] tag: 0" and, in the router table, "R 1.1.1.1 IA [400] area: 0.0.0.2, ASBR" 384# (the type marker follows the router id there). 385_OSPF_ROUTE_RE = re.compile( 386 r'^(?P<kind>[NRD])\s+(?P<type>IA|E1|E2|N1|N2)?\s*' 387 r'(?P<dest>\d{1,3}(?:\.\d{1,3}){3}(?:/\d{1,2})?)\s+' 388 r'(?:(?P<type2>IA|E1|E2|N1|N2)\s+)?' 389 r'(?:\[(?P<cost>\d+)(?:/(?P<ext_cost>\d+))?\]|(?P<discard>Discard entry))' 390 r'(?P<rest>.*)$' 391) 392 393 394def parse_ospf_routes(output: str) -> Dict[str, Dict[str, Any]]: 395 """Parse the text of ``show ip ospf route``. 396 397 Network entries (``N``) and discard entries (``D``, created by ``area range``) are 398 keyed by prefix, router entries (``R``) by router id:: 399 400 {'10.0.5.0/24': {'kind': 'N', 'type': 'IA', 'cost': 30, 'ext_cost': None, 'tag': None, 401 'area': '0.0.0.0', 'abr': False, 'asbr': False, 402 'nexthops': [('10.0.12.3', 'eth0')]}, 403 '203.0.113.0/24': {'kind': 'N', 'type': 'E2', 'cost': 10, 'ext_cost': 20, 'tag': 0, ...}, 404 '10.0.0.3': {'kind': 'R', 'type': 'intra', 'cost': 10, 'area': '0.0.0.0', 'abr': True, ...}} 405 406 ``type`` is ``'intra'`` for plain intra-area entries, ``'IA'``, ``'E1'``, ``'E2'``, 407 ``'N1'`` or ``'N2'``. ``nexthops`` holds ``(ip, interface)`` pairs, ``ip`` being 408 ``None`` for ``directly attached to`` entries. 409 """ 410 result: Dict[str, Dict[str, Any]] = {} 411 current: Dict[str, Any] | None = None 412 413 for line in output.splitlines(): 414 m = _OSPF_ROUTE_RE.match(line.strip()) 415 if m: 416 rest = m.group('rest') or '' 417 area_m = re.search(r'area:\s*(\S+?)(?:,|\s|$)', rest) 418 tag_m = re.search(r'tag:\s*(\d+)', rest) 419 current = { 420 'kind': m.group('kind'), 421 'type': m.group('type') or m.group('type2') or 'intra', 422 'cost': int(m.group('cost')) if m.group('cost') else None, 423 'ext_cost': int(m.group('ext_cost')) if m.group('ext_cost') else None, 424 'tag': int(tag_m.group(1)) if tag_m else None, 425 'area': area_m.group(1) if area_m else None, 426 'abr': 'ABR' in rest, 427 'asbr': 'ASBR' in rest, 428 'discard': bool(m.group('discard')), 429 'nexthops': [], 430 } 431 result[m.group('dest')] = current 432 continue 433 if current is None: 434 continue 435 m = re.search(r'via (\S+?),\s*(\S+)', line) 436 if m: 437 current['nexthops'].append((m.group(1), m.group(2))) 438 continue 439 m = re.search(r'directly attached to (\S+)', line) 440 if m: 441 current['nexthops'].append((None, m.group(1))) 442 continue 443 444 return result 445 446 447def get_ospf_routes(grade: Grade0, machine_name: str, step: int = 1, allow_error: bool = False) -> Dict[str, Dict[str, Any]]: 448 """Run 'vtysh -c "show ip ospf route"' and parse it (see :func:`parse_ospf_routes`). 449 Returns {} on error or empty output.""" 450 output, code = _test(grade, machine_name, 'vtysh -c "show ip ospf route"', step, allow_error) 451 if code != 0 or not output: 452 return {} 453 return parse_ospf_routes(output) 454 455 456# --------------------------------------------------------------------------- 457# ip -j route (kernel routing table) 458# --------------------------------------------------------------------------- 459 460def parse_kernel_routes(output: str) -> Dict[str, Dict[str, Any]]: 461 """Parse the JSON of ``ip -j route``. 462 463 Returns ``{prefix: {'protocol': str, 'metric': int, 'scope': str | None, 464 'nexthops': [(gateway | None, dev)]}}``; ``default`` becomes ``0.0.0.0/0`` and 465 host routes get ``/32``. ECMP routes list every next hop. When the same prefix 466 appears several times (different metrics) the first entry wins. 467 """ 468 try: 469 routes = json.loads(output) 470 except (ValueError, TypeError): 471 return {} 472 if not isinstance(routes, list): 473 return {} 474 result: Dict[str, Dict[str, Any]] = {} 475 for r in routes: 476 if not isinstance(r, dict): 477 continue 478 dst = r.get('dst') 479 if not dst: 480 continue 481 if dst == 'default': 482 dst = '0.0.0.0/0' 483 elif '/' not in dst: 484 dst += '/32' 485 if dst in result: 486 continue 487 if isinstance(r.get('nexthops'), list): 488 nexthops = [(n.get('gateway'), n.get('dev')) for n in r['nexthops'] if isinstance(n, dict)] 489 else: 490 nexthops = [(r.get('gateway'), r.get('dev'))] 491 result[dst] = { 492 'protocol': str(r.get('protocol', '')), 493 'metric': int(r.get('metric', 0) or 0), 494 'scope': r.get('scope'), 495 'nexthops': nexthops, 496 } 497 return result 498 499 500def get_kernel_routes(grade: Grade0, machine_name: str, step: int = 1, allow_error: bool = False) -> Dict[str, Dict[str, Any]]: 501 """Run 'ip -j route' and parse it (see :func:`parse_kernel_routes`). Returns {} on error.""" 502 output, code = _test(grade, machine_name, 'ip -j route', step, allow_error) 503 if code != 0 or not output: 504 return {} 505 return parse_kernel_routes(output) 506 507 508def kernel_route_gateways(routes: Dict[str, Dict[str, Any]], prefix, ospf_only: bool = True, 509 protocols=None) -> List[str]: 510 """Return the gateway list of ``prefix`` in a :func:`parse_kernel_routes` result. 511 512 ``[]`` when the prefix is absent or when its protocol is not accepted: by default 513 only OSPF routes are (``ospf_only``); ``protocols`` (e.g. ``BGP_ROUTE_PROTOCOLS``) 514 replaces that filter, ``ospf_only=False`` and ``protocols=None`` accept any route. 515 """ 516 entry = routes.get(str(prefix)) 517 if entry is None: 518 return [] 519 if protocols is not None: 520 if str(entry['protocol']) not in protocols: 521 return [] 522 elif ospf_only and not is_ospf_protocol(entry['protocol']): 523 return [] 524 return [gw for gw, _dev in entry['nexthops'] if gw] 525 526 527# --------------------------------------------------------------------------- 528# show running-config 529# --------------------------------------------------------------------------- 530 531def _empty_interface_config() -> Dict[str, Any]: 532 return { 533 'cost': None, 'priority': None, 'hello_interval': None, 'dead_interval': None, 534 'authentication': None, 'message_digest_keys': {}, 'authentication_key': None, 535 'area': None, 'bandwidth': None, 'passive': None, 'network_type': None, 536 'addresses': [], 537 } 538 539 540def _empty_area_config() -> Dict[str, Any]: 541 return {'stub': False, 'nssa': False, 'no_summary': False, 'ranges': [], 'authentication': None, 542 'default_cost': None} 543 544 545def _empty_bgp_neighbor_config() -> Dict[str, Any]: 546 return { 547 'remote_as': None, 'peer_group': None, 'description': None, 'update_source': None, 548 'timers': None, 'shutdown': False, 'password': None, 'ebgp_multihop': None, 549 'next_hop_self': False, 'route_reflector_client': False, 'default_originate': False, 550 'default_originate_route_map': None, 'route_map_in': None, 'route_map_out': None, 551 'prefix_list_in': None, 'prefix_list_out': None, 'filter_list_in': None, 'filter_list_out': None, 552 'soft_reconfiguration': False, 'send_community': True, 'activate': None, 553 } 554 555 556def _empty_router_bgp_config() -> Dict[str, Any]: 557 return { 558 'present': False, 'as': None, 'router_id': None, 'ebgp_requires_policy': True, 559 'network_import_check': True, 'default_ipv4_unicast': True, 'bestpath': [], 'cluster_id': None, 560 'default_local_preference': None, 'timers': None, 'neighbors': {}, 'networks': [], 561 'aggregate_addresses': [], 'redistribute': [], 562 } 563 564 565def _parse_router_bgp_line(rb: Dict[str, Any], words: List[str], neighbor_cfg) -> None: 566 """Apply one indented ``router bgp`` statement (router level or address-family) to ``rb``.""" 567 if not words: 568 return 569 negated = words[0] == 'no' 570 w = words[1:] if negated else words 571 if not w: 572 return 573 if w[0] in ('address-family', 'exit-address-family'): 574 return 575 if w[0] == 'neighbor' and len(w) >= 3: 576 n = neighbor_cfg(w[1]) 577 kw, arg = w[2], (w[3] if len(w) >= 4 else None) 578 if kw == 'remote-as' and arg: 579 n['remote_as'] = int(arg) if arg.isdigit() else arg 580 elif kw == 'peer-group' and arg: 581 n['peer_group'] = arg 582 elif kw == 'description': 583 n['description'] = " ".join(w[3:]) or None 584 elif kw == 'update-source' and arg: 585 n['update_source'] = arg 586 elif kw == 'timers' and len(w) >= 5 and w[3].isdigit() and w[4].isdigit(): 587 n['timers'] = (int(w[3]), int(w[4])) 588 elif kw == 'shutdown': 589 n['shutdown'] = not negated 590 elif kw == 'password': 591 n['password'] = None if negated else arg 592 elif kw == 'ebgp-multihop': 593 n['ebgp_multihop'] = None if negated else (int(arg) if arg and arg.isdigit() else 255) 594 elif kw == 'next-hop-self': 595 n['next_hop_self'] = not negated 596 elif kw == 'route-reflector-client': 597 n['route_reflector_client'] = not negated 598 elif kw == 'default-originate': 599 n['default_originate'] = not negated 600 if not negated and len(w) >= 5 and w[3] == 'route-map': 601 n['default_originate_route_map'] = w[4] 602 elif kw == 'route-map' and len(w) >= 5 and w[4] in ('in', 'out'): 603 n[f'route_map_{w[4]}'] = None if negated else w[3] 604 elif kw == 'prefix-list' and len(w) >= 5 and w[4] in ('in', 'out'): 605 n[f'prefix_list_{w[4]}'] = None if negated else w[3] 606 elif kw == 'filter-list' and len(w) >= 5 and w[4] in ('in', 'out'): 607 n[f'filter_list_{w[4]}'] = None if negated else w[3] 608 elif kw == 'soft-reconfiguration': 609 n['soft_reconfiguration'] = not negated 610 elif kw == 'send-community': 611 n['send_community'] = not negated 612 elif kw == 'activate': 613 n['activate'] = not negated 614 return 615 if w[0] == 'bgp' and len(w) >= 2: 616 if w[1] == 'router-id' and len(w) >= 3: 617 rb['router_id'] = None if negated else w[2] 618 elif w[1] == 'ebgp-requires-policy': 619 rb['ebgp_requires_policy'] = not negated 620 elif w[1] == 'network' and len(w) >= 3 and w[2] == 'import-check': 621 rb['network_import_check'] = not negated 622 elif w[1] == 'default' and len(w) >= 3 and w[2] == 'ipv4-unicast': 623 rb['default_ipv4_unicast'] = not negated 624 elif w[1] == 'default' and len(w) >= 4 and w[2] == 'local-preference': 625 rb['default_local_preference'] = None if negated else int(w[3]) 626 elif w[1] == 'bestpath' and len(w) >= 3: 627 rb['bestpath'].append(" ".join(w[2:])) 628 elif w[1] == 'cluster-id' and len(w) >= 3: 629 rb['cluster_id'] = None if negated else w[2] 630 return 631 if w[0] == 'network' and len(w) >= 2: 632 if negated: 633 if w[1] in rb['networks']: 634 rb['networks'].remove(w[1]) 635 else: 636 rb['networks'].append(w[1]) 637 elif w[0] == 'aggregate-address' and len(w) >= 2 and not negated: 638 rb['aggregate_addresses'].append((w[1], w[2:])) 639 elif w[0] == 'redistribute' and len(w) >= 2 and not negated: 640 rb['redistribute'].append(w[1]) 641 elif w[0] == 'timers' and len(w) >= 4 and w[1] == 'bgp' and w[2].isdigit() and w[3].isdigit(): 642 rb['timers'] = (int(w[2]), int(w[3])) 643 644 645def parse_frr_config(text: str) -> Dict[str, Any]: 646 """Parse an FRR configuration (``show running-config`` or ``frr.conf``). 647 648 Returns:: 649 650 {'hostname': str | None, 651 'static_routes': [(prefix, nexthop)], # nexthop may be 'blackhole' / 'Null0' 652 'router_ospf': {'present': bool, 'router_id': str | None, 653 'networks': [(prefix, area)], 'passive_interfaces': [...], 654 'passive_default': bool, 655 'areas': {area: {'stub', 'nssa', 'no_summary', 'ranges': [prefix], 656 'authentication', 'default_cost'}}, 657 'redistribute': [protocol, ...], 658 'default_information_originate': bool, 659 'default_information_always': bool, 660 'auto_cost_reference_bandwidth': int | None}, 661 'interfaces': {name: {'cost', 'priority', 'hello_interval', 'dead_interval', 662 'authentication' ('message-digest' | 'simple' | 'null' | 'key-chain'), 663 'message_digest_keys': {id: key}, 'authentication_key', 664 'area', 'bandwidth', 'passive' (True/False/None), 'network_type', 665 'addresses': ['10.0.0.1/32', ...]}}, 666 'router_bgp': {'present': bool, 'as': int | None, 'router_id': str | None, 667 'ebgp_requires_policy': bool, 'network_import_check': bool, 668 'default_ipv4_unicast': bool, 'bestpath': ['compare-routerid', ...], 669 'cluster_id', 'default_local_preference', 'timers': (keepalive, hold) | None, 670 'neighbors': {peer: {'remote_as': int | str | None, 'peer_group', 'description', 671 'update_source', 'timers': (keepalive, hold) | None, 672 'shutdown': bool, 'password', 'ebgp_multihop': int | None, 673 'next_hop_self': bool, 'route_reflector_client': bool, 674 'default_originate': bool, 'default_originate_route_map', 675 'route_map_in', 'route_map_out', 'prefix_list_in', 676 'prefix_list_out', 'filter_list_in', 'filter_list_out', 677 'soft_reconfiguration': bool, 'send_community': bool, 678 'activate': bool | None}}, 679 'networks': [prefix], 'aggregate_addresses': [(prefix, [option, ...])], 680 'redistribute': [protocol, ...]}, 681 'prefix_lists': {name: [{'seq': int | None, 'action': 'permit' | 'deny', 'prefix': str, 682 'ge': int | None, 'le': int | None}]}, 683 'route_maps': {name: [{'seq': int, 'action': 'permit' | 'deny', 684 'match': ['ip address prefix-list P', ...], 685 'set': ['local-preference 200', ...]}]}, 686 'as_path_lists': {name: [(seq | None, action, regexp)]}, 687 'community_lists': {name: [(seq | None, action, 'community ...')]}} 688 689 Area ids are normalised to dotted form (``area 1`` -> ``'0.0.0.1'``). Both the 690 router-level ``passive-interface X`` and the interface-level ``ip ospf passive`` 691 syntaxes are recognised; ``interfaces[X]['passive']`` reflects only the 692 interface-level statement, ``router_ospf['passive_interfaces']`` the router-level one. 693 In ``router bgp`` the statements of the ``address-family ipv4 unicast`` block and the 694 router-level ones are merged (FRR moves ``network``, ``next-hop-self``, ``route-map``... 695 into the address-family block in ``show running-config``). 696 """ 697 cfg: Dict[str, Any] = { 698 'hostname': None, 699 'static_routes': [], 700 'router_ospf': { 701 'present': False, 'router_id': None, 'networks': [], 'passive_interfaces': [], 702 'passive_default': False, 'areas': {}, 'redistribute': [], 703 'default_information_originate': False, 'default_information_always': False, 704 'auto_cost_reference_bandwidth': None, 705 }, 706 'interfaces': {}, 707 'router_bgp': _empty_router_bgp_config(), 708 'prefix_lists': {}, 709 'route_maps': {}, 710 'as_path_lists': {}, 711 'community_lists': {}, 712 } 713 section: Optional[str] = None # 'router_ospf' | 'router_bgp' | 'interface' | 'route_map' | 'other' | None 714 iface: Dict[str, Any] | None = None 715 rm_entry: Dict[str, Any] | None = None 716 ro = cfg['router_ospf'] 717 rb = cfg['router_bgp'] 718 719 def neighbor_cfg(peer: str) -> Dict[str, Any]: 720 if peer not in rb['neighbors']: 721 rb['neighbors'][peer] = _empty_bgp_neighbor_config() 722 return rb['neighbors'][peer] 723 724 def seq_of(w: List[str]): 725 """Return (seq, rest) for a 'seq N ...' prefix of the word list.""" 726 if len(w) >= 2 and w[0] == 'seq' and w[1].isdigit(): 727 return int(w[1]), w[2:] 728 return None, w 729 730 def area_cfg(area: str) -> Dict[str, Any]: 731 key = normalize_area(area) 732 if key not in ro['areas']: 733 ro['areas'][key] = _empty_area_config() 734 return ro['areas'][key] 735 736 for raw in text.splitlines(): 737 line = raw.rstrip() 738 if not line.strip() or line.strip() == '!': 739 continue 740 stripped = line.strip() 741 words = stripped.split() 742 743 if not line[0].isspace(): 744 # top-level line: opens a section or is a global statement 745 if stripped in ('exit', 'end', 'exit-address-family'): 746 section, iface, rm_entry = None, None, None 747 continue 748 if words[0] == 'interface' and len(words) >= 2: 749 section = 'interface' 750 iface = cfg['interfaces'].setdefault(words[1], _empty_interface_config()) 751 continue 752 if words[0] == 'router' and len(words) >= 2 and words[1] == 'ospf': 753 section = 'router_ospf' 754 iface = None 755 ro['present'] = True 756 continue 757 if words[0] == 'router' and len(words) >= 3 and words[1] == 'bgp': 758 section, iface = 'router_bgp', None 759 rb['present'] = True 760 rb['as'] = int(words[2]) if words[2].isdigit() else words[2] 761 continue 762 if words[0] == 'route-map' and len(words) >= 4 and words[2] in ('permit', 'deny'): 763 section, iface = 'route_map', None 764 rm_entry = {'seq': int(words[3]) if words[3].isdigit() else words[3], 'action': words[2], 765 'match': [], 'set': []} 766 cfg['route_maps'].setdefault(words[1], []).append(rm_entry) 767 continue 768 if words[0] == 'router' or words[0] in ('vrf', 'route-map', 'key', 'line', 'segment-routing'): 769 section, iface = 'other', None 770 continue 771 section, iface, rm_entry = None, None, None 772 if words[0] == 'hostname' and len(words) >= 2: 773 cfg['hostname'] = words[1] 774 elif words[0] == 'ip' and len(words) >= 4 and words[1] == 'route': 775 cfg['static_routes'].append((words[2], words[3])) 776 elif words[:2] == ['ip', 'prefix-list'] and len(words) >= 5: 777 # ip prefix-list NAME [seq N] permit|deny PREFIX [ge X] [le Y] 778 seq, rest = seq_of(words[3:]) 779 if len(rest) >= 2 and rest[0] in ('permit', 'deny'): 780 entry = {'seq': seq, 'action': rest[0], 'prefix': rest[1], 'ge': None, 'le': None} 781 for i in range(2, len(rest) - 1): 782 if rest[i] in ('ge', 'le') and rest[i + 1].isdigit(): 783 entry[rest[i]] = int(rest[i + 1]) 784 cfg['prefix_lists'].setdefault(words[2], []).append(entry) 785 elif words[:3] == ['bgp', 'as-path', 'access-list'] and len(words) >= 6: 786 # bgp as-path access-list NAME [seq N] permit|deny REGEXP 787 seq, rest = seq_of(words[4:]) 788 if len(rest) >= 2 and rest[0] in ('permit', 'deny'): 789 cfg['as_path_lists'].setdefault(words[3], []).append((seq, rest[0], " ".join(rest[1:]))) 790 elif words[:2] == ['bgp', 'community-list'] and len(words) >= 5: 791 # bgp community-list [standard|expanded] NAME [seq N] permit|deny COMMUNITY... 792 rest = words[2:] 793 if rest and rest[0] in ('standard', 'expanded'): 794 rest = rest[1:] 795 if len(rest) >= 3: 796 name = rest[0] 797 seq, rest2 = seq_of(rest[1:]) 798 if len(rest2) >= 2 and rest2[0] in ('permit', 'deny'): 799 cfg['community_lists'].setdefault(name, []).append((seq, rest2[0], " ".join(rest2[1:]))) 800 continue 801 802 # indented line: belongs to the current section 803 if section == 'interface' and iface is not None: 804 if words[:2] == ['ip', 'ospf'] and len(words) >= 3: 805 sub = words[2] 806 arg = words[3] if len(words) >= 4 else None 807 if sub == 'cost' and arg: 808 iface['cost'] = int(arg) 809 elif sub == 'priority' and arg: 810 iface['priority'] = int(arg) 811 elif sub == 'hello-interval' and arg: 812 iface['hello_interval'] = int(arg) 813 elif sub == 'dead-interval' and arg: 814 iface['dead_interval'] = int(arg) if arg.isdigit() else arg 815 elif sub == 'authentication': 816 iface['authentication'] = arg if arg else 'simple' 817 elif sub == 'authentication-key' and arg: 818 iface['authentication_key'] = arg 819 elif sub == 'message-digest-key' and len(words) >= 6 and words[4] == 'md5': 820 iface['message_digest_keys'][int(arg)] = words[5] 821 elif sub == 'area' and arg: 822 iface['area'] = normalize_area(arg) 823 elif sub == 'passive': 824 iface['passive'] = True 825 elif sub == 'network' and arg: 826 iface['network_type'] = arg 827 elif words[:3] == ['no', 'ip', 'ospf'] and len(words) >= 4 and words[3] == 'passive': 828 iface['passive'] = False 829 elif words[0] == 'bandwidth' and len(words) >= 2: 830 iface['bandwidth'] = int(words[1]) 831 elif words[:2] == ['ip', 'address'] and len(words) >= 3: 832 iface['addresses'].append(words[2]) 833 continue 834 835 if section == 'route_map' and rm_entry is not None: 836 if words[0] == 'match' and len(words) >= 2: 837 rm_entry['match'].append(" ".join(words[1:])) 838 elif words[0] == 'set' and len(words) >= 2: 839 rm_entry['set'].append(" ".join(words[1:])) 840 continue 841 842 if section == 'router_bgp': 843 _parse_router_bgp_line(rb, words, neighbor_cfg) 844 continue 845 846 if section == 'router_ospf': 847 if words[0] in ('ospf', 'router-id') and 'router-id' in words: 848 ro['router_id'] = words[-1] 849 elif words[0] == 'network' and len(words) >= 4 and words[2] == 'area': 850 ro['networks'].append((words[1], normalize_area(words[3]))) 851 elif words[0] == 'passive-interface' and len(words) >= 2: 852 if words[1] == 'default': 853 ro['passive_default'] = True 854 else: 855 ro['passive_interfaces'].append(words[1]) 856 elif words[0] == 'no' and len(words) >= 3 and words[1] == 'passive-interface': 857 if words[2] in ro['passive_interfaces']: 858 ro['passive_interfaces'].remove(words[2]) 859 elif words[0] == 'area' and len(words) >= 3: 860 a = area_cfg(words[1]) 861 kw = words[2] 862 if kw == 'stub': 863 a['stub'] = True 864 a['no_summary'] = 'no-summary' in words[3:] 865 elif kw == 'nssa': 866 a['nssa'] = True 867 a['no_summary'] = 'no-summary' in words[3:] 868 elif kw == 'range' and len(words) >= 4: 869 a['ranges'].append(words[3]) 870 elif kw == 'authentication': 871 a['authentication'] = words[3] if len(words) >= 4 else 'simple' 872 elif kw == 'default-cost' and len(words) >= 4: 873 a['default_cost'] = int(words[3]) 874 elif words[0] == 'redistribute' and len(words) >= 2: 875 ro['redistribute'].append(words[1]) 876 elif words[:2] == ['default-information', 'originate']: 877 ro['default_information_originate'] = True 878 ro['default_information_always'] = 'always' in words[2:] 879 elif words[:2] == ['auto-cost', 'reference-bandwidth'] and len(words) >= 3: 880 ro['auto_cost_reference_bandwidth'] = int(words[2]) 881 continue 882 883 return cfg 884 885 886def get_frr_running_config(grade: Grade0, machine_name: str, step: int = 1, allow_error: bool = False) -> Dict[str, Any]: 887 """Run 'vtysh -c "show running-config"' and parse it (see :func:`parse_frr_config`). 888 Returns {} on error or empty output.""" 889 output, code = _test(grade, machine_name, 'vtysh -c "show running-config"', step, allow_error) 890 if code != 0 or not output: 891 return {} 892 return parse_frr_config(output) 893 894 895def expected_auto_cost(reference_bandwidth: int, interface_bandwidth: int) -> int: 896 """OSPF cost FRR computes for an interface: ``round(reference / bandwidth)`` clamped to 897 ``[1, 65535]`` (both in Mbit/s; the default reference is 100000 and a veth reports 10000, 898 hence the default cost of 10).""" 899 if not interface_bandwidth: 900 return 10 901 cost = int(reference_bandwidth / interface_bandwidth + 0.5) 902 return max(1, min(65535, cost)) 903 904 905# --------------------------------------------------------------------------- 906# BGP (json output of vtysh) 907# --------------------------------------------------------------------------- 908 909def _vtysh_json(grade: Grade0, machine_name: str, command: str, step: int = 1, allow_error: bool = False) -> Any: 910 """Run ``vtysh -c "<command> json"`` and return the decoded JSON object. 911 912 Returns ``{}`` on the registration-pass placeholder, on a non-zero exit code, when 913 BGP is not configured (``% ...`` error line) or when the output is not a JSON object. 914 """ 915 output, code = _test(grade, machine_name, f'vtysh -c "{command} json"', step, allow_error) 916 if code != 0 or not output: 917 return {} 918 start = output.find('{') 919 if start < 0: 920 return {} 921 try: 922 data = json.loads(output[start:]) 923 except (ValueError, TypeError): 924 return {} 925 return data if isinstance(data, dict) else {} 926 927 928def _int_or_none(value) -> Optional[int]: 929 try: 930 return int(value) 931 except (TypeError, ValueError): 932 return None 933 934 935def as_list_from_string(path) -> List[int]: 936 """``'65100 65000'`` -> ``[65100, 65000]`` (AS sets ``{65100,65000}`` keep their members).""" 937 if not path: 938 return [] 939 return [int(tok) for tok in re.findall(r'\d+', str(path))] 940 941 942# -- show bgp ipv4 unicast summary ------------------------------------------ 943 944def parse_bgp_summary(data: Any) -> Dict[str, Any]: 945 """Parse the JSON of ``show bgp ipv4 unicast summary``. 946 947 Accepts the flat address-family object or the ``{'ipv4Unicast': {...}}`` wrapper of 948 ``show bgp summary json``. Returns ``{}`` when no BGP process answers, else:: 949 950 {'router_id': '10.0.0.1', 'as': 65001, 951 'peers': {'10.1.1.2': {'remote_as': 65100, 'state': 'Established', 'established': True, 952 'policy_blocked': False, # eBGP peer without in/out policy (RFC 8212) 953 'pfx_rcd': 3, 'pfx_snt': 1, 'description': 'ISP-A', 954 'uptime': '00:12:34'}}} 955 """ 956 if not isinstance(data, dict): 957 return {} 958 if 'ipv4Unicast' in data and isinstance(data['ipv4Unicast'], dict): 959 data = data['ipv4Unicast'] 960 if 'routerId' not in data and 'peers' not in data: 961 return {} 962 peers: Dict[str, Dict[str, Any]] = {} 963 for ip, p in (data.get('peers') or {}).items(): 964 if not isinstance(p, dict): 965 continue 966 state = p.get('state') 967 peers[ip] = { 968 'remote_as': _int_or_none(p.get('remoteAs')), 969 'state': state, 970 'established': state == 'Established', 971 'policy_blocked': p.get('peerState') == 'Policy', 972 'pfx_rcd': _int_or_none(p.get('pfxRcd')) or 0, 973 'pfx_snt': _int_or_none(p.get('pfxSnt')) or 0, 974 'description': p.get('desc'), 975 'uptime': p.get('peerUptime'), 976 } 977 return {'router_id': data.get('routerId'), 'as': _int_or_none(data.get('as')), 'peers': peers} 978 979 980def get_bgp_summary(grade: Grade0, machine_name: str, step: int = 1, allow_error: bool = False) -> Dict[str, Any]: 981 """Run 'vtysh -c "show bgp ipv4 unicast summary json"' (see :func:`parse_bgp_summary`). 982 Returns {} when BGP is not running.""" 983 return parse_bgp_summary(_vtysh_json(grade, machine_name, 'show bgp ipv4 unicast summary', step, allow_error)) 984 985 986# -- show bgp neighbors X ----------------------------------------------------- 987 988def parse_bgp_neighbor(data: Any, peer: str = None) -> Dict[str, Any]: 989 """Parse the JSON of ``show bgp neighbors <peer>``. 990 991 The output is keyed by peer address; ``peer`` selects an entry (the first one when 992 omitted). Returns ``{}`` when the neighbour does not exist, else:: 993 994 {'peer': '10.1.1.2', 'remote_as': 65100, 'local_as': 65001, 'state': 'Established', 995 'established': True, 'link': 'external' | 'internal', 'description': 'ISP-A', 996 'remote_router_id': '100.100.100.100', 'local_router_id': '10.0.3.1', 997 'hold_time': 30, 'keepalive': 10, # negotiated, seconds 998 'configured_hold_time': 30, 'configured_keepalive': 10, 999 'local_host': '10.0.3.1', 'foreign_host': '10.0.3.2', 'update_source': 'lo' | None, 1000 'accepted_prefixes': 3, 'sent_prefixes': 1, 1001 'route_reflector_client': False, 'next_hop_self': False, 'default_originate': False, 1002 'default_originate_route_map': None, 1003 'route_map_in': 'LP-IN', 'route_map_out': None, 'prefix_list_in': None, 'prefix_list_out': None, 1004 'send_community': True, 'af': {...ipv4Unicast...}, 'raw': {...}} 1005 """ 1006 if not isinstance(data, dict) or not data: 1007 return {} 1008 if 'bgpNoSuchNeighbor' in data or 'warning' in data: 1009 return {} 1010 if peer is not None: 1011 entry = data.get(str(peer)) 1012 else: 1013 entry = next((v for v in data.values() if isinstance(v, dict)), None) 1014 if not isinstance(entry, dict) or 'bgpState' not in entry: 1015 return {} 1016 af = ((entry.get('addressFamilyInfo') or {}).get('ipv4Unicast')) or {} 1017 if not isinstance(af, dict): 1018 af = {} 1019 peer_ip = peer if peer is not None else next(iter(data)) 1020 1021 def msecs(key) -> Optional[int]: 1022 v = _int_or_none(entry.get(key)) 1023 return None if v is None else v // 1000 1024 1025 if entry.get('nbrExternalLink'): 1026 link = 'external' 1027 elif entry.get('nbrInternalLink'): 1028 link = 'internal' 1029 else: 1030 link = None 1031 return { 1032 'peer': peer_ip, 1033 'remote_as': _int_or_none(entry.get('remoteAs')), 1034 'local_as': _int_or_none(entry.get('localAs')), 1035 'state': entry.get('bgpState'), 1036 'established': entry.get('bgpState') == 'Established', 1037 'link': link, 1038 'description': entry.get('nbrDesc'), 1039 'remote_router_id': entry.get('remoteRouterId'), 1040 'local_router_id': entry.get('localRouterId'), 1041 'hold_time': msecs('bgpTimerHoldTimeMsecs'), 1042 'keepalive': msecs('bgpTimerKeepAliveIntervalMsecs'), 1043 'configured_hold_time': msecs('bgpTimerConfiguredHoldTimeMsecs'), 1044 'configured_keepalive': msecs('bgpTimerConfiguredKeepAliveIntervalMsecs'), 1045 'local_host': entry.get('hostLocal'), 1046 'foreign_host': entry.get('hostForeign'), 1047 'update_source': entry.get('updateSource'), 1048 'accepted_prefixes': _int_or_none(af.get('acceptedPrefixCounter')) or 0, 1049 'sent_prefixes': _int_or_none(af.get('sentPrefixCounter')) or 0, 1050 'route_reflector_client': bool(af.get('routeReflectorClient')), 1051 'next_hop_self': bool(af.get('routerAlwaysNextHop') or af.get('nextHopSelf')), 1052 'default_originate': bool(af.get('defaultSent') or af.get('defaultNotSent')), 1053 'default_originate_route_map': af.get('defaultRouteMap'), 1054 'route_map_in': af.get('routeMapForIncomingAdvertisements'), 1055 'route_map_out': af.get('routeMapForOutgoingAdvertisements'), 1056 'prefix_list_in': af.get('incomingUpdatePrefixFilterList'), 1057 'prefix_list_out': af.get('outgoingUpdatePrefixFilterList'), 1058 'send_community': 'commAttriSentToNbr' in af if af else True, 1059 'af': af, 1060 'raw': entry, 1061 } 1062 1063 1064def get_bgp_neighbor(grade: Grade0, machine_name: str, peer, step: int = 1, allow_error: bool = False) -> Dict[str, Any]: 1065 """Run 'vtysh -c "show bgp neighbors <peer> json"' (see :func:`parse_bgp_neighbor`). 1066 Returns {} when BGP is not running or the neighbour is unknown.""" 1067 return parse_bgp_neighbor(_vtysh_json(grade, machine_name, f'show bgp neighbors {peer}', step, allow_error), 1068 peer=str(peer)) 1069 1070 1071# -- show bgp ipv4 unicast (whole table) -------------------------------------- 1072 1073def _parse_bgp_path_entry(e: Dict[str, Any]) -> Dict[str, Any]: 1074 """Common fields of a path in ``show bgp ipv4 unicast json`` and ``show bgp ipv4 unicast X json``.""" 1075 aspath = e.get('aspath') 1076 if isinstance(aspath, dict): 1077 aspath_str = aspath.get('string') or '' 1078 else: 1079 aspath_str = e.get('path') or '' 1080 if aspath_str == 'Local': 1081 aspath_str = '' 1082 best = e.get('bestpath') 1083 if isinstance(best, dict): 1084 reason = best.get('selectionReason') 1085 best = bool(best.get('overall')) 1086 else: 1087 reason = e.get('selectionReason') 1088 best = bool(best) 1089 nexthops = [n for n in (e.get('nexthops') or []) if isinstance(n, dict)] 1090 used = next((n for n in nexthops if n.get('used')), nexthops[0] if nexthops else {}) 1091 peer = e.get('peer') 1092 if isinstance(peer, dict): 1093 peer_id, peer_router_id = peer.get('peerId'), peer.get('routerId') 1094 path_from = peer.get('type') or e.get('pathFrom') 1095 else: 1096 peer_id, peer_router_id = e.get('peerId'), None 1097 path_from = e.get('pathFrom') 1098 community = e.get('community') 1099 if isinstance(community, dict): 1100 communities = list(community.get('list') or []) 1101 if not communities and community.get('string'): 1102 communities = str(community['string']).split() 1103 else: 1104 communities = [] 1105 cluster = e.get('clusterList') 1106 if isinstance(cluster, dict): 1107 cluster_list = list(cluster.get('list') or []) 1108 elif isinstance(cluster, list): 1109 cluster_list = list(cluster) 1110 else: 1111 cluster_list = [] 1112 return { 1113 'valid': bool(e.get('valid')), 1114 'best': best, 1115 'reason': reason, 1116 'path_from': path_from, 1117 'nexthop': used.get('ip'), 1118 'nexthop_used': bool(used.get('used')), 1119 'accessible': used.get('accessible'), 1120 'peer': peer_id, 1121 'peer_router_id': peer_router_id, 1122 'aspath': aspath_str, 1123 'as_list': as_list_from_string(aspath_str), 1124 'locprf': _int_or_none(e.get('locPrf')), 1125 'med': _int_or_none(e.get('metric')), 1126 'weight': _int_or_none(e.get('weight')) or 0, 1127 'origin': e.get('origin'), 1128 'local': bool(e.get('local') or e.get('sourced') or peer_id in ('(unspec)', '0.0.0.0')), 1129 'originator_id': e.get('originatorId'), 1130 'cluster_list': cluster_list, 1131 'communities': communities, 1132 } 1133 1134 1135def parse_bgp_routes(data: Any) -> Dict[str, List[Dict[str, Any]]]: 1136 """Parse the JSON of ``show bgp ipv4 unicast`` (the whole BGP table). 1137 1138 Returns ``{prefix: [path, ...]}`` (``{}`` when BGP is not running), each path being:: 1139 1140 {'valid': True, 'best': True, 'reason': 'Local Pref', 'path_from': 'internal', 1141 'nexthop': '10.0.3.3', 'nexthop_used': True, 'accessible': None, 1142 'peer': '10.0.3.2', 'peer_router_id': None, 'aspath': '65200 65000', 1143 'as_list': [65200, 65000], 'locprf': 200, 'med': 0, 'weight': 0, 'origin': 'IGP', 1144 'local': False, 'originator_id': None, 'cluster_list': [], 'communities': []} 1145 1146 A ``network`` statement whose prefix has no route in the RIB (``bgp network 1147 import-check``) yields a path with ``valid`` and ``best`` both ``False``. 1148 """ 1149 if not isinstance(data, dict): 1150 return {} 1151 routes = data.get('routes') 1152 if not isinstance(routes, dict): 1153 return {} 1154 result: Dict[str, List[Dict[str, Any]]] = {} 1155 for prefix, entries in routes.items(): 1156 if isinstance(entries, dict): 1157 entries = [entries] 1158 if not isinstance(entries, list): 1159 continue 1160 result[prefix] = [_parse_bgp_path_entry(e) for e in entries if isinstance(e, dict)] 1161 return result 1162 1163 1164def get_bgp_routes(grade: Grade0, machine_name: str, step: int = 1, allow_error: bool = False) -> Dict[str, List[Dict[str, Any]]]: 1165 """Run 'vtysh -c "show bgp ipv4 unicast json"' (see :func:`parse_bgp_routes`). Returns {} on error.""" 1166 return parse_bgp_routes(_vtysh_json(grade, machine_name, 'show bgp ipv4 unicast', step, allow_error)) 1167 1168 1169def bgp_best_path(routes: Dict[str, List[Dict[str, Any]]], prefix) -> Dict[str, Any]: 1170 """Return the best path of ``prefix`` in a :func:`parse_bgp_routes` result (``{}`` when none).""" 1171 for p in routes.get(str(prefix), []): 1172 if p.get('best'): 1173 return p 1174 return {} 1175 1176 1177def bgp_paths(routes: Dict[str, List[Dict[str, Any]]], prefix, nexthop=None, peer=None, first_as: int = None, 1178 ends_with=None, path_from: str = None, valid: bool = None) -> List[Dict[str, Any]]: 1179 """Return the paths of ``prefix`` matching every given criterion (``ends_with`` is a 1180 list of AS numbers compared with the end of ``as_list``).""" 1181 result = [] 1182 for p in routes.get(str(prefix), []): 1183 if nexthop is not None and p.get('nexthop') != str(nexthop): 1184 continue 1185 if peer is not None and p.get('peer') != str(peer): 1186 continue 1187 if first_as is not None and (not p['as_list'] or p['as_list'][0] != int(first_as)): 1188 continue 1189 if ends_with is not None: 1190 tail = [int(a) for a in ends_with] 1191 if p['as_list'][-len(tail):] != tail: 1192 continue 1193 if path_from is not None and p.get('path_from') != path_from: 1194 continue 1195 if valid is not None and bool(p.get('valid')) != valid: 1196 continue 1197 result.append(p) 1198 return result 1199 1200 1201# -- show bgp ipv4 unicast PREFIX --------------------------------------------- 1202 1203def parse_bgp_prefix(data: Any) -> Dict[str, Any]: 1204 """Parse the JSON of ``show bgp ipv4 unicast <prefix>`` (detailed paths). 1205 1206 Returns ``{'prefix': None, 'paths': []}`` when the prefix is unknown, else 1207 ``{'prefix': '10.1.0.0/24', 'paths': [path, ...]}`` with the fields of 1208 :func:`parse_bgp_routes` plus ``accessible``, ``peer_router_id``, ``originator_id``, 1209 ``cluster_list`` (route reflection) and ``communities`` (``['65001:300', ...]``). 1210 """ 1211 if not isinstance(data, dict) or not isinstance(data.get('paths'), list): 1212 return {'prefix': None, 'paths': []} 1213 return {'prefix': data.get('prefix'), 1214 'paths': [_parse_bgp_path_entry(e) for e in data['paths'] if isinstance(e, dict)]} 1215 1216 1217def get_bgp_prefix(grade: Grade0, machine_name: str, prefix, step: int = 1, allow_error: bool = False) -> Dict[str, Any]: 1218 """Run 'vtysh -c "show bgp ipv4 unicast <prefix> json"' (see :func:`parse_bgp_prefix`).""" 1219 return parse_bgp_prefix(_vtysh_json(grade, machine_name, f'show bgp ipv4 unicast {prefix}', step, allow_error)) 1220 1221 1222# -- show bgp ipv4 unicast neighbors X advertised-routes ------------------------ 1223 1224def parse_bgp_advertised_routes(data: Any) -> Dict[str, Dict[str, Any]]: 1225 """Parse the JSON of ``show bgp ipv4 unicast neighbors <peer> advertised-routes`` 1226 (the routes sent to that peer, after the outbound policy). 1227 1228 Returns ``{prefix: {'nexthop', 'aspath', 'as_list', 'locprf', 'med', 'weight', 'origin'}}`` 1229 (``{}`` on error or when nothing is advertised). 1230 """ 1231 if not isinstance(data, dict): 1232 return {} 1233 adv = data.get('advertisedRoutes') 1234 if not isinstance(adv, dict): 1235 return {} 1236 result: Dict[str, Dict[str, Any]] = {} 1237 for prefix, e in adv.items(): 1238 if not isinstance(e, dict): 1239 continue 1240 path = e.get('path') or '' 1241 result[prefix] = { 1242 'nexthop': e.get('nextHop'), 1243 'aspath': path, 1244 'as_list': as_list_from_string(path), 1245 'locprf': _int_or_none(e.get('locPrf')), 1246 'med': _int_or_none(e.get('metric')), 1247 'weight': _int_or_none(e.get('weight')) or 0, 1248 'origin': e.get('bgpOriginCode') or e.get('origin'), 1249 } 1250 return result 1251 1252 1253def get_bgp_advertised_routes(grade: Grade0, machine_name: str, peer, step: int = 1, 1254 allow_error: bool = False) -> Dict[str, Dict[str, Any]]: 1255 """Run 'vtysh -c "show bgp ipv4 unicast neighbors <peer> advertised-routes json"' 1256 (see :func:`parse_bgp_advertised_routes`).""" 1257 return parse_bgp_advertised_routes( 1258 _vtysh_json(grade, machine_name, f'show bgp ipv4 unicast neighbors {peer} advertised-routes', step, allow_error)) 1259 1260 1261# --------------------------------------------------------------------------- 1262# RIP (text output of vtysh: ripd has no json form) 1263# --------------------------------------------------------------------------- 1264 1265#: metric of an unreachable route 1266RIP_INFINITY = 16 1267#: sub-codes of `show ip rip`: where the route of the RIP table comes from 1268RIP_SUB_CODES = {'n': 'normal', 's': 'static', 'd': 'default', 'r': 'redistribute', 'i': 'interface'} 1269 1270_RIP_ROUTE_RE = re.compile( 1271 r'^(?P<code>[A-Z])\((?P<sub>[a-z])\)\s+(?P<prefix>\d{1,3}(?:\.\d{1,3}){3}/\d{1,2})\s+' 1272 r'(?P<nexthop>\d{1,3}(?:\.\d{1,3}){3}|blackhole|V6 Address Hidden)\s+(?P<metric>\d+)\s+' 1273 r'(?P<source>self(?:\s+\((?P<external>[^)]*)\))?|\d{1,3}(?:\.\d{1,3}){3})\s+(?P<tag>\d+)' 1274 r'(?:\s+(?P<time>\d\d:\d\d(?::\d\d)?))?\s*$') 1275_RIP_VERSION = r'(?:1 2|1|2|none)' 1276_RIP_INTERFACE_RE = re.compile( 1277 rf'^\s+(?P<name>\S+)\s+(?P<send>{_RIP_VERSION})\s+(?P<receive>{_RIP_VERSION})(?:\s+(?P<key_chain>\S+))?\s*$') 1278_RIP_SOURCE_RE = re.compile( 1279 r'^\s+(?P<gateway>\d{1,3}(?:\.\d{1,3}){3})\s+(?P<bad_packets>\d+)\s+(?P<bad_routes>\d+)\s+' 1280 r'(?P<distance>\d+)\s+(?P<last_update>\S+)\s*$') 1281 1282 1283def parse_rip_routes(output: str) -> Dict[str, List[Dict[str, Any]]]: 1284 """Parse ``show ip rip`` (the RIP table of ripd, not the kernel one). 1285 1286 Returns ``{prefix: [path, ...]}`` — one path per row, an ECMP route has several — with 1287 ``code`` (``'R'`` RIP, ``'C'`` connected, ``'S'`` static, ``'K'`` kernel...), ``sub_code`` 1288 (:data:`RIP_SUB_CODES`: ``'n'`` learned from a neighbour, ``'i'`` interface, ``'r'`` 1289 redistributed, ``'d'`` default, ``'s'`` static RIP route), ``nexthop`` (``None`` for a local 1290 route), ``metric``, ``source`` (the neighbour the route was learned from, ``None`` for the 1291 router's own routes), ``external`` (``'static:20'`` of ``self (static:20)``), ``tag`` and 1292 ``time`` (the timeout or garbage-collection timer, ``None`` when the route has none). 1293 ``{}`` when ripd has no instance (``% RIP instance not found``) or on the placeholder. 1294 """ 1295 result: Dict[str, List[Dict[str, Any]]] = {} 1296 for line in (output or '').splitlines(): 1297 m = _RIP_ROUTE_RE.match(line) 1298 if not m: 1299 continue 1300 nexthop = m.group('nexthop') 1301 source = m.group('source') 1302 result.setdefault(m.group('prefix'), []).append({ 1303 'code': m.group('code'), 1304 'sub_code': m.group('sub'), 1305 'nexthop': None if nexthop == '0.0.0.0' else nexthop, 1306 'metric': int(m.group('metric')), 1307 'source': None if source.startswith('self') else source, 1308 'external': m.group('external'), 1309 'tag': int(m.group('tag')), 1310 'time': m.group('time'), 1311 }) 1312 return result 1313 1314 1315def get_rip_routes(grade: Grade0, machine_name: str, step: int = 1, 1316 allow_error: bool = False) -> Dict[str, List[Dict[str, Any]]]: 1317 """Run 'vtysh -c "show ip rip"' and parse it (see :func:`parse_rip_routes`). ``{}`` on error.""" 1318 output, code = _test(grade, machine_name, 'vtysh -c "show ip rip"', step, allow_error) 1319 if code != 0 or not output: 1320 return {} 1321 return parse_rip_routes(output) 1322 1323 1324def rip_route_metric(routes: Dict[str, List[Dict[str, Any]]], prefix, learned: bool = None) -> Optional[int]: 1325 """Smallest metric of *prefix* in a :func:`parse_rip_routes` result, ``None`` when the prefix 1326 is absent. ``learned=True`` only looks at the paths learned from a neighbour (code ``R``, 1327 sub-code ``n``), ``learned=False`` at the router's own ones (interface, redistributed...).""" 1328 metrics = [p['metric'] for p in routes.get(str(prefix), []) 1329 if learned is None or learned == (p['source'] is not None)] 1330 return min(metrics) if metrics else None 1331 1332 1333def rip_route_sources(routes: Dict[str, List[Dict[str, Any]]], prefix) -> List[str]: 1334 """Neighbours *prefix* was learned from in a :func:`parse_rip_routes` result (reachable 1335 paths only: a route in garbage collection, metric 16, is left out).""" 1336 return [p['source'] for p in routes.get(str(prefix), []) 1337 if p['source'] is not None and p['metric'] < RIP_INFINITY] 1338 1339 1340def parse_rip_status(output: str) -> Dict[str, Any]: 1341 """Parse ``show ip rip status``. 1342 1343 Returns ``{'update', 'timeout', 'garbage'}`` (the three timers of ``timers basic``, seconds), 1344 ``default_metric``, ``redistribute`` (``['connected', 'static']``), ``send_version`` / 1345 ``receive_version`` (``'1'``, ``'2'``, ``'1 2'``), ``interfaces`` (``{name: {'send', 1346 'receive', 'key_chain'}}``: the interfaces RIP runs on, passive ones included), ``networks`` 1347 (the ``network`` statements: prefixes and interface names, then the ``neighbor`` addresses), 1348 ``passive_interfaces``, ``sources`` (``{gateway: {'bad_packets', 'bad_routes', 'distance', 1349 'last_update'}}``: every neighbour a packet was received from) and ``distance``. 1350 ``{}`` when ripd has no instance or on the placeholder. 1351 """ 1352 text = output or '' 1353 if 'Routing Protocol is "rip"' not in text: 1354 return {} 1355 status: Dict[str, Any] = { 1356 'update': None, 'timeout': None, 'garbage': None, 'default_metric': None, 'redistribute': [], 1357 'send_version': None, 'receive_version': None, 'interfaces': {}, 'networks': [], 1358 'passive_interfaces': [], 'sources': {}, 'distance': None, 1359 } 1360 section = None 1361 for line in text.splitlines(): 1362 stripped = line.strip() 1363 if not stripped: 1364 continue 1365 m = re.match(r'Sending updates every (\d+) seconds', stripped) 1366 if m: 1367 status['update'] = int(m.group(1)) 1368 continue 1369 m = re.match(r'Timeout after (\d+) seconds, garbage collect after (\d+) seconds', stripped) 1370 if m: 1371 status['timeout'], status['garbage'] = int(m.group(1)), int(m.group(2)) 1372 continue 1373 m = re.match(r'Default redistribution metric is (\d+)', stripped) 1374 if m: 1375 status['default_metric'] = int(m.group(1)) 1376 continue 1377 if stripped.startswith('Redistributing:'): 1378 status['redistribute'] = stripped[len('Redistributing:'):].split() 1379 continue 1380 m = re.match(r'Default version control: send version (.+?), receive (any version|version (.+?))\s*$', 1381 stripped) 1382 if m: 1383 status['send_version'] = m.group(1).strip() 1384 status['receive_version'] = '1 2' if m.group(2) == 'any version' else m.group(3).strip() 1385 continue 1386 m = re.match(r'Distance: \(default is (\d+)\)', stripped) 1387 if m: 1388 status['distance'] = int(m.group(1)) 1389 section = None 1390 continue 1391 if re.match(r'Interface\s+Send\s+Recv', stripped): 1392 section = 'interfaces' 1393 continue 1394 if stripped == 'Routing for Networks:': 1395 section = 'networks' 1396 continue 1397 if stripped == 'Passive Interface(s):': 1398 section = 'passive' 1399 continue 1400 if stripped == 'Routing Information Sources:': 1401 section = 'sources' 1402 continue 1403 if section == 'interfaces': 1404 m = _RIP_INTERFACE_RE.match(line) 1405 if m: 1406 status['interfaces'][m.group('name')] = { 1407 'send': m.group('send'), 'receive': m.group('receive'), 'key_chain': m.group('key_chain')} 1408 elif section == 'networks': 1409 status['networks'].append(stripped) 1410 elif section == 'passive': 1411 status['passive_interfaces'].append(stripped) 1412 elif section == 'sources': 1413 m = _RIP_SOURCE_RE.match(line) 1414 if m: 1415 status['sources'][m.group('gateway')] = { 1416 'bad_packets': int(m.group('bad_packets')), 'bad_routes': int(m.group('bad_routes')), 1417 'distance': int(m.group('distance')), 'last_update': m.group('last_update')} 1418 return status 1419 1420 1421def get_rip_status(grade: Grade0, machine_name: str, step: int = 1, allow_error: bool = False) -> Dict[str, Any]: 1422 """Run 'vtysh -c "show ip rip status"' and parse it (see :func:`parse_rip_status`). 1423 ``{}`` on error or when RIP is not configured.""" 1424 output, code = _test(grade, machine_name, 'vtysh -c "show ip rip status"', step, allow_error) 1425 if code != 0 or not output: 1426 return {} 1427 return parse_rip_status(output)
42def normalize_area(area: str) -> str: 43 """Return an OSPF area id in dotted form: ``'1'`` -> ``'0.0.0.1'``, ``'0.0.0.1'`` unchanged.""" 44 area = str(area).strip() 45 if area.isdigit(): 46 return str(IPv4Address(int(area))) 47 return area
Return an OSPF area id in dotted form: '1' -> '0.0.0.1', '0.0.0.1' unchanged.
50def is_ospf_protocol(protocol) -> bool: 51 """True when a kernel route ``protocol`` field (``ip -j route``) denotes an OSPF route.""" 52 return str(protocol) in OSPF_ROUTE_PROTOCOLS
True when a kernel route protocol field (ip -j route) denotes an OSPF route.
55def is_bgp_protocol(protocol) -> bool: 56 """True when a kernel route ``protocol`` field (``ip -j route``) denotes a BGP route.""" 57 return str(protocol) in BGP_ROUTE_PROTOCOLS
True when a kernel route protocol field (ip -j route) denotes a BGP route.
60def is_rip_protocol(protocol) -> bool: 61 """True when a kernel route ``protocol`` field (``ip -j route``) denotes a RIP route.""" 62 return str(protocol) in RIP_ROUTE_PROTOCOLS
True when a kernel route protocol field (ip -j route) denotes a RIP route.
65def is_ripng_protocol(protocol) -> bool: 66 """True when a kernel route ``protocol`` field (``ip -j -6 route``) denotes a RIPng route.""" 67 return str(protocol) in RIPNG_ROUTE_PROTOCOLS
True when a kernel route protocol field (ip -j -6 route) denotes a RIPng route.
74def parse_ospf_interfaces(output: str) -> Dict[str, Dict[str, Any]]: 75 """Parse the text of ``show ip ospf interface`` (see :func:`get_ospf_interfaces`).""" 76 result: Dict[str, Dict[str, Any]] = {} 77 current: Dict[str, Any] | None = None 78 79 for line in output.splitlines(): 80 # Interface header: "ethX is up" 81 m = re.match(r'^(\S+) is (up|down)', line) 82 if m: 83 current = { 84 'state': m.group(2), 85 'ospf_enabled': True, 86 'mtu': None, 'bandwidth': None, 87 'internet_address': None, 'broadcast': None, 'area': None, 88 'router_id': None, 'network_type': None, 'cost': None, 89 'ospf_state': None, 'priority': None, 'passive': False, 90 'hello_interval': None, 'dead_interval': None, 'retransmit_interval': None, 91 'neighbor_count': None, 'adjacent_neighbor_count': None, 92 'dr_id': None, 'dr_address': None, 93 'bdr_id': None, 'bdr_address': None, 94 } 95 result[m.group(1)] = current 96 continue 97 98 if current is None: 99 continue 100 101 if 'OSPF not enabled on this interface' in line: 102 current['ospf_enabled'] = False 103 continue 104 105 # ifindex 13, MTU 1500 bytes, BW 10000 Mbit <UP,LOWER_UP,BROADCAST,RUNNING,MULTICAST> 106 m = re.search(r'MTU (\d+) bytes,\s*BW (\d+) Mbit', line) 107 if m: 108 current['mtu'] = int(m.group(1)) 109 current['bandwidth'] = int(m.group(2)) 110 continue 111 112 # Internet Address 10.150.180.224/28, Broadcast 10.150.180.239, Area 0.0.0.0 113 m = re.search(r'Internet Address (\S+?),\s*Broadcast (\S+?),\s*Area (\S+)', line) 114 if m: 115 current['internet_address'] = m.group(1) 116 current['broadcast'] = m.group(2) 117 current['area'] = m.group(3) 118 continue 119 120 # Internet Address 10.0.0.1/30, Area 0.0.0.0 (point-to-point, no broadcast) 121 m = re.search(r'Internet Address (\S+?),\s*Area (\S+)', line) 122 if m: 123 current['internet_address'] = m.group(1) 124 current['area'] = m.group(2) 125 continue 126 127 # Router ID 10.150.180.224, Network Type BROADCAST, Cost: 10 128 m = re.search(r'Router ID (\S+?),\s*Network Type (\S+?),\s*Cost:\s*(\d+)', line) 129 if m: 130 current['router_id'] = m.group(1) 131 current['network_type'] = m.group(2) 132 current['cost'] = int(m.group(3)) 133 continue 134 135 # Transmit Delay is 1 sec, State Backup, Priority 1 136 m = re.search(r'State (\S+?),\s*Priority (\d+)', line) 137 if m: 138 current['ospf_state'] = m.group(1).rstrip(',') 139 current['priority'] = int(m.group(2)) 140 continue 141 142 # Designated Router (ID) 172.23.52.228 Interface Address 10.150.180.230/28 143 m = re.search(r'Designated Router \(ID\) (\S+)\s+Interface Address (\S+)', line) 144 if m and 'Backup' not in line: 145 current['dr_id'] = m.group(1) 146 current['dr_address'] = m.group(2) 147 continue 148 149 # Backup Designated Router (ID) 10.150.180.224, Interface Address 10.150.180.224 150 m = re.search(r'Backup Designated Router \(ID\) (\S+?),\s*Interface Address (\S+)', line) 151 if m: 152 current['bdr_id'] = m.group(1) 153 current['bdr_address'] = m.group(2) 154 continue 155 156 # Timer intervals configured, Hello 10s, Dead 40s, Wait 40s, Retransmit 5 157 m = re.search(r'Hello (\d+)s,\s*Dead (\d+)s,.*Retransmit (\d+)', line) 158 if m: 159 current['hello_interval'] = int(m.group(1)) 160 current['dead_interval'] = int(m.group(2)) 161 current['retransmit_interval'] = int(m.group(3)) 162 continue 163 164 # No Hellos (Passive interface) 165 if 'No Hellos (Passive interface)' in line: 166 current['passive'] = True 167 continue 168 169 # Neighbor Count is 1, Adjacent neighbor count is 1 170 m = re.search(r'Neighbor Count is (\d+),\s*Adjacent neighbor count is (\d+)', line) 171 if m: 172 current['neighbor_count'] = int(m.group(1)) 173 current['adjacent_neighbor_count'] = int(m.group(2)) 174 continue 175 176 return result
Parse the text of show ip ospf interface (see get_ospf_interfaces()).
179def get_ospf_interfaces(grade: Grade0, machine_name: str, step: int = 1, 180 allow_error: bool = False) -> Dict[str, Dict[str, Any]]: 181 """Run 'vtysh -c "show ip ospf interface"' and parse the output. 182 183 Returns a dict mapping interface name -> dict of parsed fields: 184 state : 'up' | 'down' 185 ospf_enabled : bool (False for "OSPF not enabled on this interface" entries) 186 mtu : int 187 bandwidth : int (Mbit, the "BW" field: interface speed or `bandwidth` setting) 188 internet_address : str e.g. '10.150.180.224/28' 189 broadcast : str e.g. '10.150.180.239' 190 area : str e.g. '0.0.0.0' 191 router_id : str 192 network_type : str e.g. 'BROADCAST' 193 cost : int 194 ospf_state : str e.g. 'Backup', 'DR', 'DROther' 195 priority : int 196 passive : bool ("No Hellos (Passive interface)") 197 hello_interval : int (seconds) 198 dead_interval : int (seconds) 199 retransmit_interval : int (seconds) 200 neighbor_count : int 201 adjacent_neighbor_count : int 202 dr_id : str | None 203 dr_address : str | None 204 bdr_id : str | None 205 bdr_address : str | None 206 207 Returns {} on error or empty output. 208 """ 209 output, code = _test(grade, machine_name, 'vtysh -c "show ip ospf interface"', step, allow_error) 210 if code != 0 or not output: 211 return {} 212 return parse_ospf_interfaces(output)
Run 'vtysh -c "show ip ospf interface"' and parse the output.
Returns a dict mapping interface name -> dict of parsed fields:
state : 'up' | 'down'
ospf_enabled : bool (False for "OSPF not enabled on this interface" entries)
mtu : int
bandwidth : int (Mbit, the "BW" field: interface speed or bandwidth setting)
internet_address : str e.g. '10.150.180.224/28'
broadcast : str e.g. '10.150.180.239'
area : str e.g. '0.0.0.0'
router_id : str
network_type : str e.g. 'BROADCAST'
cost : int
ospf_state : str e.g. 'Backup', 'DR', 'DROther'
priority : int
passive : bool ("No Hellos (Passive interface)")
hello_interval : int (seconds)
dead_interval : int (seconds)
retransmit_interval : int (seconds)
neighbor_count : int
adjacent_neighbor_count : int
dr_id : str | None
dr_address : str | None
bdr_id : str | None
bdr_address : str | None
Returns {} on error or empty output.
219def parse_ospf_neighbors(output: str) -> List[Dict[str, Any]]: 220 """Parse the text of ``show ip ospf neighbor``. 221 222 Data rows look like (the "Up Time" column exists in FRR >= 7.5 only):: 223 224 Neighbor ID Pri State Up Time Dead Time Address Interface RXmtL RqstL DBsmL 225 10.0.0.2 1 Full/DR 2m34s 36.245s 10.0.12.2 eth0:10.0.12.1 0 0 0 226 227 Rows are parsed from the end (three counters, interface, address, dead time) so 228 both layouts work. Non-data lines are skipped. 229 """ 230 result: List[Dict[str, Any]] = [] 231 for line in output.splitlines(): 232 fields = line.split() 233 if len(fields) < 9 or not _IPV4_RE.match(fields[0]): 234 continue 235 state, _, role = fields[2].partition('/') 236 interface, _, local_address = fields[-4].partition(':') 237 try: 238 priority: Optional[int] = int(fields[1]) 239 except ValueError: 240 priority = None 241 result.append({ 242 'neighbor_id': fields[0], 243 'priority': priority, 244 'state': state, 245 'role': role or None, 246 'up_time': fields[3] if len(fields) >= 10 else None, 247 'dead_time': fields[-6], 248 'address': fields[-5], 249 'interface': interface, 250 'local_address': local_address or None, 251 }) 252 return result
Parse the text of show ip ospf neighbor.
Data rows look like (the "Up Time" column exists in FRR >= 7.5 only)::
Neighbor ID Pri State Up Time Dead Time Address Interface RXmtL RqstL DBsmL
10.0.0.2 1 Full/DR 2m34s 36.245s 10.0.12.2 eth0:10.0.12.1 0 0 0
Rows are parsed from the end (three counters, interface, address, dead time) so both layouts work. Non-data lines are skipped.
255def get_ospf_neighbors(grade: Grade0, machine_name: str, step: int = 1, 256 allow_error: bool = False) -> List[Dict[str, Any]]: 257 """Run 'vtysh -c "show ip ospf neighbor"' and return one dict per neighbour: 258 ``neighbor_id, priority, state ('Full', '2-Way', ...), role ('DR', 'Backup', 259 'DROther', '-'), up_time, dead_time, address, interface, local_address``. 260 Returns [] on error or empty output.""" 261 output, code = _test(grade, machine_name, 'vtysh -c "show ip ospf neighbor"', step, allow_error) 262 if code != 0 or not output: 263 return [] 264 return parse_ospf_neighbors(output)
Run 'vtysh -c "show ip ospf neighbor"' and return one dict per neighbour:
neighbor_id, priority, state ('Full', '2-Way', ...), role ('DR', 'Backup',
'DROther', '-'), up_time, dead_time, address, interface, local_address.
Returns [] on error or empty output.
267def find_neighbor(neighbors: List[Dict[str, Any]], neighbor_id: str = None, interface: str = None, 268 address: str = None) -> Optional[Dict[str, Any]]: 269 """Return the first neighbour of :func:`get_ospf_neighbors` matching every given criterion.""" 270 for n in neighbors: 271 if neighbor_id is not None and n['neighbor_id'] != str(neighbor_id): 272 continue 273 if interface is not None and n['interface'] != interface: 274 continue 275 if address is not None and n['address'] != str(address): 276 continue 277 return n 278 return None
Return the first neighbour of get_ospf_neighbors() matching every given criterion.
281def neighbor_is_full(neighbors: List[Dict[str, Any]], neighbor_id: str = None, interface: str = None, 282 address: str = None) -> bool: 283 """True when a neighbour matching the criteria exists and is in state Full.""" 284 n = find_neighbor(neighbors, neighbor_id=neighbor_id, interface=interface, address=address) 285 return n is not None and n['state'] == 'Full'
True when a neighbour matching the criteria exists and is in state Full.
292def parse_ospf_info(output: str) -> Dict[str, Any]: 293 """Parse the text of ``show ip ospf``. 294 295 Returns ``{}`` when no router id is found (OSPF not running), else:: 296 297 {'router_id': '10.0.0.4', 'abr': True, 'asbr': False, 298 'areas': {'0.0.0.0': {'type': 'backbone', 'no_summary': False, 'interfaces': 3, 299 'active_interfaces': 3, 'full_neighbors': 2, 300 'authentication': 'no'}, ...}} 301 302 ``type`` is ``'backbone'``, ``'stub'``, ``'nssa'`` or ``'normal'``; 303 ``authentication`` is ``'no'``, ``'simple password'`` or ``'message digest'``. 304 """ 305 info: Dict[str, Any] = {'router_id': None, 'abr': False, 'asbr': False, 'areas': {}} 306 current: Dict[str, Any] | None = None 307 308 for line in output.splitlines(): 309 m = re.search(r'OSPF Routing Process, Router ID:\s*(\S+)', line) 310 if m: 311 info['router_id'] = m.group(1) 312 continue 313 if 'This router is an ABR' in line: 314 info['abr'] = True 315 continue 316 if 'This router is an ASBR' in line: 317 info['asbr'] = True 318 continue 319 m = re.match(r'^\s*Area ID:\s*(\S+)(?:\s*\((.*?)\))?', line) 320 if m: 321 desc = (m.group(2) or '').strip() 322 low = desc.lower() 323 if low.startswith('backbone'): 324 area_type = 'backbone' 325 elif low.startswith('stub'): 326 area_type = 'stub' 327 elif low.startswith('nssa'): 328 area_type = 'nssa' 329 else: 330 area_type = 'normal' 331 current = { 332 'type': area_type, 333 'no_summary': 'no summary' in low, 334 'interfaces': None, 'active_interfaces': None, 335 'full_neighbors': None, 'authentication': None, 336 } 337 info['areas'][m.group(1)] = current 338 continue 339 if current is None: 340 continue 341 m = re.search(r'Number of interfaces in this area:\s*Total:\s*(\d+),\s*Active:\s*(\d+)', line) 342 if m: 343 current['interfaces'] = int(m.group(1)) 344 current['active_interfaces'] = int(m.group(2)) 345 continue 346 m = re.search(r'Number of fully adjacent neighbors in this area:\s*(\d+)', line) 347 if m: 348 current['full_neighbors'] = int(m.group(1)) 349 continue 350 m = re.search(r'Area has (.+?) authentication', line) 351 if m: 352 current['authentication'] = m.group(1).strip() 353 continue 354 355 if info['router_id'] is None: 356 return {} 357 return info
Parse the text of show ip ospf.
Returns {} when no router id is found (OSPF not running), else::
{'router_id': '10.0.0.4', 'abr': True, 'asbr': False,
'areas': {'0.0.0.0': {'type': 'backbone', 'no_summary': False, 'interfaces': 3,
'active_interfaces': 3, 'full_neighbors': 2,
'authentication': 'no'}, ...}}
type is 'backbone', 'stub', 'nssa' or 'normal';
authentication is 'no', 'simple password' or 'message digest'.
360def get_ospf_info(grade: Grade0, machine_name: str, step: int = 1, allow_error: bool = False) -> Dict[str, Any]: 361 """Run 'vtysh -c "show ip ospf"' and return the router-level state (see :func:`parse_ospf_info`). 362 Returns {} when OSPF is not running.""" 363 output, code = _test(grade, machine_name, 'vtysh -c "show ip ospf"', step, allow_error) 364 if code != 0 or not output: 365 return {} 366 return parse_ospf_info(output)
Run 'vtysh -c "show ip ospf"' and return the router-level state (see parse_ospf_info()).
Returns {} when OSPF is not running.
369def router_id_map(grade: Grade0, machine_names, step: int = 1, allow_error: bool = True) -> Dict[str, str]: 370 """Return ``{router_id: machine_name}`` for every machine whose OSPF process answers.""" 371 result: Dict[str, str] = {} 372 for m in machine_names: 373 rid = get_ospf_info(grade, m, step=step, allow_error=allow_error).get('router_id') 374 if rid: 375 result[rid] = m 376 return result
Return {router_id: machine_name} for every machine whose OSPF process answers.
395def parse_ospf_routes(output: str) -> Dict[str, Dict[str, Any]]: 396 """Parse the text of ``show ip ospf route``. 397 398 Network entries (``N``) and discard entries (``D``, created by ``area range``) are 399 keyed by prefix, router entries (``R``) by router id:: 400 401 {'10.0.5.0/24': {'kind': 'N', 'type': 'IA', 'cost': 30, 'ext_cost': None, 'tag': None, 402 'area': '0.0.0.0', 'abr': False, 'asbr': False, 403 'nexthops': [('10.0.12.3', 'eth0')]}, 404 '203.0.113.0/24': {'kind': 'N', 'type': 'E2', 'cost': 10, 'ext_cost': 20, 'tag': 0, ...}, 405 '10.0.0.3': {'kind': 'R', 'type': 'intra', 'cost': 10, 'area': '0.0.0.0', 'abr': True, ...}} 406 407 ``type`` is ``'intra'`` for plain intra-area entries, ``'IA'``, ``'E1'``, ``'E2'``, 408 ``'N1'`` or ``'N2'``. ``nexthops`` holds ``(ip, interface)`` pairs, ``ip`` being 409 ``None`` for ``directly attached to`` entries. 410 """ 411 result: Dict[str, Dict[str, Any]] = {} 412 current: Dict[str, Any] | None = None 413 414 for line in output.splitlines(): 415 m = _OSPF_ROUTE_RE.match(line.strip()) 416 if m: 417 rest = m.group('rest') or '' 418 area_m = re.search(r'area:\s*(\S+?)(?:,|\s|$)', rest) 419 tag_m = re.search(r'tag:\s*(\d+)', rest) 420 current = { 421 'kind': m.group('kind'), 422 'type': m.group('type') or m.group('type2') or 'intra', 423 'cost': int(m.group('cost')) if m.group('cost') else None, 424 'ext_cost': int(m.group('ext_cost')) if m.group('ext_cost') else None, 425 'tag': int(tag_m.group(1)) if tag_m else None, 426 'area': area_m.group(1) if area_m else None, 427 'abr': 'ABR' in rest, 428 'asbr': 'ASBR' in rest, 429 'discard': bool(m.group('discard')), 430 'nexthops': [], 431 } 432 result[m.group('dest')] = current 433 continue 434 if current is None: 435 continue 436 m = re.search(r'via (\S+?),\s*(\S+)', line) 437 if m: 438 current['nexthops'].append((m.group(1), m.group(2))) 439 continue 440 m = re.search(r'directly attached to (\S+)', line) 441 if m: 442 current['nexthops'].append((None, m.group(1))) 443 continue 444 445 return result
Parse the text of show ip ospf route.
Network entries (N) and discard entries (D, created by area range) are
keyed by prefix, router entries (R) by router id::
{'10.0.5.0/24': {'kind': 'N', 'type': 'IA', 'cost': 30, 'ext_cost': None, 'tag': None,
'area': '0.0.0.0', 'abr': False, 'asbr': False,
'nexthops': [('10.0.12.3', 'eth0')]},
'203.0.113.0/24': {'kind': 'N', 'type': 'E2', 'cost': 10, 'ext_cost': 20, 'tag': 0, ...},
'10.0.0.3': {'kind': 'R', 'type': 'intra', 'cost': 10, 'area': '0.0.0.0', 'abr': True, ...}}
type is 'intra' for plain intra-area entries, 'IA', 'E1', 'E2',
'N1' or 'N2'. nexthops holds (ip, interface) pairs, ip being
None for directly attached to entries.
448def get_ospf_routes(grade: Grade0, machine_name: str, step: int = 1, allow_error: bool = False) -> Dict[str, Dict[str, Any]]: 449 """Run 'vtysh -c "show ip ospf route"' and parse it (see :func:`parse_ospf_routes`). 450 Returns {} on error or empty output.""" 451 output, code = _test(grade, machine_name, 'vtysh -c "show ip ospf route"', step, allow_error) 452 if code != 0 or not output: 453 return {} 454 return parse_ospf_routes(output)
Run 'vtysh -c "show ip ospf route"' and parse it (see parse_ospf_routes()).
Returns {} on error or empty output.
461def parse_kernel_routes(output: str) -> Dict[str, Dict[str, Any]]: 462 """Parse the JSON of ``ip -j route``. 463 464 Returns ``{prefix: {'protocol': str, 'metric': int, 'scope': str | None, 465 'nexthops': [(gateway | None, dev)]}}``; ``default`` becomes ``0.0.0.0/0`` and 466 host routes get ``/32``. ECMP routes list every next hop. When the same prefix 467 appears several times (different metrics) the first entry wins. 468 """ 469 try: 470 routes = json.loads(output) 471 except (ValueError, TypeError): 472 return {} 473 if not isinstance(routes, list): 474 return {} 475 result: Dict[str, Dict[str, Any]] = {} 476 for r in routes: 477 if not isinstance(r, dict): 478 continue 479 dst = r.get('dst') 480 if not dst: 481 continue 482 if dst == 'default': 483 dst = '0.0.0.0/0' 484 elif '/' not in dst: 485 dst += '/32' 486 if dst in result: 487 continue 488 if isinstance(r.get('nexthops'), list): 489 nexthops = [(n.get('gateway'), n.get('dev')) for n in r['nexthops'] if isinstance(n, dict)] 490 else: 491 nexthops = [(r.get('gateway'), r.get('dev'))] 492 result[dst] = { 493 'protocol': str(r.get('protocol', '')), 494 'metric': int(r.get('metric', 0) or 0), 495 'scope': r.get('scope'), 496 'nexthops': nexthops, 497 } 498 return result
Parse the JSON of ip -j route.
Returns {prefix: {'protocol': str, 'metric': int, 'scope': str | None,
'nexthops': [(gateway | None, dev)]}}; default becomes 0.0.0.0/0 and
host routes get /32. ECMP routes list every next hop. When the same prefix
appears several times (different metrics) the first entry wins.
501def get_kernel_routes(grade: Grade0, machine_name: str, step: int = 1, allow_error: bool = False) -> Dict[str, Dict[str, Any]]: 502 """Run 'ip -j route' and parse it (see :func:`parse_kernel_routes`). Returns {} on error.""" 503 output, code = _test(grade, machine_name, 'ip -j route', step, allow_error) 504 if code != 0 or not output: 505 return {} 506 return parse_kernel_routes(output)
Run 'ip -j route' and parse it (see parse_kernel_routes()). Returns {} on error.
509def kernel_route_gateways(routes: Dict[str, Dict[str, Any]], prefix, ospf_only: bool = True, 510 protocols=None) -> List[str]: 511 """Return the gateway list of ``prefix`` in a :func:`parse_kernel_routes` result. 512 513 ``[]`` when the prefix is absent or when its protocol is not accepted: by default 514 only OSPF routes are (``ospf_only``); ``protocols`` (e.g. ``BGP_ROUTE_PROTOCOLS``) 515 replaces that filter, ``ospf_only=False`` and ``protocols=None`` accept any route. 516 """ 517 entry = routes.get(str(prefix)) 518 if entry is None: 519 return [] 520 if protocols is not None: 521 if str(entry['protocol']) not in protocols: 522 return [] 523 elif ospf_only and not is_ospf_protocol(entry['protocol']): 524 return [] 525 return [gw for gw, _dev in entry['nexthops'] if gw]
Return the gateway list of prefix in a parse_kernel_routes() result.
[] when the prefix is absent or when its protocol is not accepted: by default
only OSPF routes are (ospf_only); protocols (e.g. BGP_ROUTE_PROTOCOLS)
replaces that filter, ospf_only=False and protocols=None accept any route.
646def parse_frr_config(text: str) -> Dict[str, Any]: 647 """Parse an FRR configuration (``show running-config`` or ``frr.conf``). 648 649 Returns:: 650 651 {'hostname': str | None, 652 'static_routes': [(prefix, nexthop)], # nexthop may be 'blackhole' / 'Null0' 653 'router_ospf': {'present': bool, 'router_id': str | None, 654 'networks': [(prefix, area)], 'passive_interfaces': [...], 655 'passive_default': bool, 656 'areas': {area: {'stub', 'nssa', 'no_summary', 'ranges': [prefix], 657 'authentication', 'default_cost'}}, 658 'redistribute': [protocol, ...], 659 'default_information_originate': bool, 660 'default_information_always': bool, 661 'auto_cost_reference_bandwidth': int | None}, 662 'interfaces': {name: {'cost', 'priority', 'hello_interval', 'dead_interval', 663 'authentication' ('message-digest' | 'simple' | 'null' | 'key-chain'), 664 'message_digest_keys': {id: key}, 'authentication_key', 665 'area', 'bandwidth', 'passive' (True/False/None), 'network_type', 666 'addresses': ['10.0.0.1/32', ...]}}, 667 'router_bgp': {'present': bool, 'as': int | None, 'router_id': str | None, 668 'ebgp_requires_policy': bool, 'network_import_check': bool, 669 'default_ipv4_unicast': bool, 'bestpath': ['compare-routerid', ...], 670 'cluster_id', 'default_local_preference', 'timers': (keepalive, hold) | None, 671 'neighbors': {peer: {'remote_as': int | str | None, 'peer_group', 'description', 672 'update_source', 'timers': (keepalive, hold) | None, 673 'shutdown': bool, 'password', 'ebgp_multihop': int | None, 674 'next_hop_self': bool, 'route_reflector_client': bool, 675 'default_originate': bool, 'default_originate_route_map', 676 'route_map_in', 'route_map_out', 'prefix_list_in', 677 'prefix_list_out', 'filter_list_in', 'filter_list_out', 678 'soft_reconfiguration': bool, 'send_community': bool, 679 'activate': bool | None}}, 680 'networks': [prefix], 'aggregate_addresses': [(prefix, [option, ...])], 681 'redistribute': [protocol, ...]}, 682 'prefix_lists': {name: [{'seq': int | None, 'action': 'permit' | 'deny', 'prefix': str, 683 'ge': int | None, 'le': int | None}]}, 684 'route_maps': {name: [{'seq': int, 'action': 'permit' | 'deny', 685 'match': ['ip address prefix-list P', ...], 686 'set': ['local-preference 200', ...]}]}, 687 'as_path_lists': {name: [(seq | None, action, regexp)]}, 688 'community_lists': {name: [(seq | None, action, 'community ...')]}} 689 690 Area ids are normalised to dotted form (``area 1`` -> ``'0.0.0.1'``). Both the 691 router-level ``passive-interface X`` and the interface-level ``ip ospf passive`` 692 syntaxes are recognised; ``interfaces[X]['passive']`` reflects only the 693 interface-level statement, ``router_ospf['passive_interfaces']`` the router-level one. 694 In ``router bgp`` the statements of the ``address-family ipv4 unicast`` block and the 695 router-level ones are merged (FRR moves ``network``, ``next-hop-self``, ``route-map``... 696 into the address-family block in ``show running-config``). 697 """ 698 cfg: Dict[str, Any] = { 699 'hostname': None, 700 'static_routes': [], 701 'router_ospf': { 702 'present': False, 'router_id': None, 'networks': [], 'passive_interfaces': [], 703 'passive_default': False, 'areas': {}, 'redistribute': [], 704 'default_information_originate': False, 'default_information_always': False, 705 'auto_cost_reference_bandwidth': None, 706 }, 707 'interfaces': {}, 708 'router_bgp': _empty_router_bgp_config(), 709 'prefix_lists': {}, 710 'route_maps': {}, 711 'as_path_lists': {}, 712 'community_lists': {}, 713 } 714 section: Optional[str] = None # 'router_ospf' | 'router_bgp' | 'interface' | 'route_map' | 'other' | None 715 iface: Dict[str, Any] | None = None 716 rm_entry: Dict[str, Any] | None = None 717 ro = cfg['router_ospf'] 718 rb = cfg['router_bgp'] 719 720 def neighbor_cfg(peer: str) -> Dict[str, Any]: 721 if peer not in rb['neighbors']: 722 rb['neighbors'][peer] = _empty_bgp_neighbor_config() 723 return rb['neighbors'][peer] 724 725 def seq_of(w: List[str]): 726 """Return (seq, rest) for a 'seq N ...' prefix of the word list.""" 727 if len(w) >= 2 and w[0] == 'seq' and w[1].isdigit(): 728 return int(w[1]), w[2:] 729 return None, w 730 731 def area_cfg(area: str) -> Dict[str, Any]: 732 key = normalize_area(area) 733 if key not in ro['areas']: 734 ro['areas'][key] = _empty_area_config() 735 return ro['areas'][key] 736 737 for raw in text.splitlines(): 738 line = raw.rstrip() 739 if not line.strip() or line.strip() == '!': 740 continue 741 stripped = line.strip() 742 words = stripped.split() 743 744 if not line[0].isspace(): 745 # top-level line: opens a section or is a global statement 746 if stripped in ('exit', 'end', 'exit-address-family'): 747 section, iface, rm_entry = None, None, None 748 continue 749 if words[0] == 'interface' and len(words) >= 2: 750 section = 'interface' 751 iface = cfg['interfaces'].setdefault(words[1], _empty_interface_config()) 752 continue 753 if words[0] == 'router' and len(words) >= 2 and words[1] == 'ospf': 754 section = 'router_ospf' 755 iface = None 756 ro['present'] = True 757 continue 758 if words[0] == 'router' and len(words) >= 3 and words[1] == 'bgp': 759 section, iface = 'router_bgp', None 760 rb['present'] = True 761 rb['as'] = int(words[2]) if words[2].isdigit() else words[2] 762 continue 763 if words[0] == 'route-map' and len(words) >= 4 and words[2] in ('permit', 'deny'): 764 section, iface = 'route_map', None 765 rm_entry = {'seq': int(words[3]) if words[3].isdigit() else words[3], 'action': words[2], 766 'match': [], 'set': []} 767 cfg['route_maps'].setdefault(words[1], []).append(rm_entry) 768 continue 769 if words[0] == 'router' or words[0] in ('vrf', 'route-map', 'key', 'line', 'segment-routing'): 770 section, iface = 'other', None 771 continue 772 section, iface, rm_entry = None, None, None 773 if words[0] == 'hostname' and len(words) >= 2: 774 cfg['hostname'] = words[1] 775 elif words[0] == 'ip' and len(words) >= 4 and words[1] == 'route': 776 cfg['static_routes'].append((words[2], words[3])) 777 elif words[:2] == ['ip', 'prefix-list'] and len(words) >= 5: 778 # ip prefix-list NAME [seq N] permit|deny PREFIX [ge X] [le Y] 779 seq, rest = seq_of(words[3:]) 780 if len(rest) >= 2 and rest[0] in ('permit', 'deny'): 781 entry = {'seq': seq, 'action': rest[0], 'prefix': rest[1], 'ge': None, 'le': None} 782 for i in range(2, len(rest) - 1): 783 if rest[i] in ('ge', 'le') and rest[i + 1].isdigit(): 784 entry[rest[i]] = int(rest[i + 1]) 785 cfg['prefix_lists'].setdefault(words[2], []).append(entry) 786 elif words[:3] == ['bgp', 'as-path', 'access-list'] and len(words) >= 6: 787 # bgp as-path access-list NAME [seq N] permit|deny REGEXP 788 seq, rest = seq_of(words[4:]) 789 if len(rest) >= 2 and rest[0] in ('permit', 'deny'): 790 cfg['as_path_lists'].setdefault(words[3], []).append((seq, rest[0], " ".join(rest[1:]))) 791 elif words[:2] == ['bgp', 'community-list'] and len(words) >= 5: 792 # bgp community-list [standard|expanded] NAME [seq N] permit|deny COMMUNITY... 793 rest = words[2:] 794 if rest and rest[0] in ('standard', 'expanded'): 795 rest = rest[1:] 796 if len(rest) >= 3: 797 name = rest[0] 798 seq, rest2 = seq_of(rest[1:]) 799 if len(rest2) >= 2 and rest2[0] in ('permit', 'deny'): 800 cfg['community_lists'].setdefault(name, []).append((seq, rest2[0], " ".join(rest2[1:]))) 801 continue 802 803 # indented line: belongs to the current section 804 if section == 'interface' and iface is not None: 805 if words[:2] == ['ip', 'ospf'] and len(words) >= 3: 806 sub = words[2] 807 arg = words[3] if len(words) >= 4 else None 808 if sub == 'cost' and arg: 809 iface['cost'] = int(arg) 810 elif sub == 'priority' and arg: 811 iface['priority'] = int(arg) 812 elif sub == 'hello-interval' and arg: 813 iface['hello_interval'] = int(arg) 814 elif sub == 'dead-interval' and arg: 815 iface['dead_interval'] = int(arg) if arg.isdigit() else arg 816 elif sub == 'authentication': 817 iface['authentication'] = arg if arg else 'simple' 818 elif sub == 'authentication-key' and arg: 819 iface['authentication_key'] = arg 820 elif sub == 'message-digest-key' and len(words) >= 6 and words[4] == 'md5': 821 iface['message_digest_keys'][int(arg)] = words[5] 822 elif sub == 'area' and arg: 823 iface['area'] = normalize_area(arg) 824 elif sub == 'passive': 825 iface['passive'] = True 826 elif sub == 'network' and arg: 827 iface['network_type'] = arg 828 elif words[:3] == ['no', 'ip', 'ospf'] and len(words) >= 4 and words[3] == 'passive': 829 iface['passive'] = False 830 elif words[0] == 'bandwidth' and len(words) >= 2: 831 iface['bandwidth'] = int(words[1]) 832 elif words[:2] == ['ip', 'address'] and len(words) >= 3: 833 iface['addresses'].append(words[2]) 834 continue 835 836 if section == 'route_map' and rm_entry is not None: 837 if words[0] == 'match' and len(words) >= 2: 838 rm_entry['match'].append(" ".join(words[1:])) 839 elif words[0] == 'set' and len(words) >= 2: 840 rm_entry['set'].append(" ".join(words[1:])) 841 continue 842 843 if section == 'router_bgp': 844 _parse_router_bgp_line(rb, words, neighbor_cfg) 845 continue 846 847 if section == 'router_ospf': 848 if words[0] in ('ospf', 'router-id') and 'router-id' in words: 849 ro['router_id'] = words[-1] 850 elif words[0] == 'network' and len(words) >= 4 and words[2] == 'area': 851 ro['networks'].append((words[1], normalize_area(words[3]))) 852 elif words[0] == 'passive-interface' and len(words) >= 2: 853 if words[1] == 'default': 854 ro['passive_default'] = True 855 else: 856 ro['passive_interfaces'].append(words[1]) 857 elif words[0] == 'no' and len(words) >= 3 and words[1] == 'passive-interface': 858 if words[2] in ro['passive_interfaces']: 859 ro['passive_interfaces'].remove(words[2]) 860 elif words[0] == 'area' and len(words) >= 3: 861 a = area_cfg(words[1]) 862 kw = words[2] 863 if kw == 'stub': 864 a['stub'] = True 865 a['no_summary'] = 'no-summary' in words[3:] 866 elif kw == 'nssa': 867 a['nssa'] = True 868 a['no_summary'] = 'no-summary' in words[3:] 869 elif kw == 'range' and len(words) >= 4: 870 a['ranges'].append(words[3]) 871 elif kw == 'authentication': 872 a['authentication'] = words[3] if len(words) >= 4 else 'simple' 873 elif kw == 'default-cost' and len(words) >= 4: 874 a['default_cost'] = int(words[3]) 875 elif words[0] == 'redistribute' and len(words) >= 2: 876 ro['redistribute'].append(words[1]) 877 elif words[:2] == ['default-information', 'originate']: 878 ro['default_information_originate'] = True 879 ro['default_information_always'] = 'always' in words[2:] 880 elif words[:2] == ['auto-cost', 'reference-bandwidth'] and len(words) >= 3: 881 ro['auto_cost_reference_bandwidth'] = int(words[2]) 882 continue 883 884 return cfg
Parse an FRR configuration (show running-config or frr.conf).
Returns::
{'hostname': str | None,
'static_routes': [(prefix, nexthop)], # nexthop may be 'blackhole' / 'Null0'
'router_ospf': {'present': bool, 'router_id': str | None,
'networks': [(prefix, area)], 'passive_interfaces': [...],
'passive_default': bool,
'areas': {area: {'stub', 'nssa', 'no_summary', 'ranges': [prefix],
'authentication', 'default_cost'}},
'redistribute': [protocol, ...],
'default_information_originate': bool,
'default_information_always': bool,
'auto_cost_reference_bandwidth': int | None},
'interfaces': {name: {'cost', 'priority', 'hello_interval', 'dead_interval',
'authentication' ('message-digest' | 'simple' | 'null' | 'key-chain'),
'message_digest_keys': {id: key}, 'authentication_key',
'area', 'bandwidth', 'passive' (True/False/None), 'network_type',
'addresses': ['10.0.0.1/32', ...]}},
'router_bgp': {'present': bool, 'as': int | None, 'router_id': str | None,
'ebgp_requires_policy': bool, 'network_import_check': bool,
'default_ipv4_unicast': bool, 'bestpath': ['compare-routerid', ...],
'cluster_id', 'default_local_preference', 'timers': (keepalive, hold) | None,
'neighbors': {peer: {'remote_as': int | str | None, 'peer_group', 'description',
'update_source', 'timers': (keepalive, hold) | None,
'shutdown': bool, 'password', 'ebgp_multihop': int | None,
'next_hop_self': bool, 'route_reflector_client': bool,
'default_originate': bool, 'default_originate_route_map',
'route_map_in', 'route_map_out', 'prefix_list_in',
'prefix_list_out', 'filter_list_in', 'filter_list_out',
'soft_reconfiguration': bool, 'send_community': bool,
'activate': bool | None}},
'networks': [prefix], 'aggregate_addresses': [(prefix, [option, ...])],
'redistribute': [protocol, ...]},
'prefix_lists': {name: [{'seq': int | None, 'action': 'permit' | 'deny', 'prefix': str,
'ge': int | None, 'le': int | None}]},
'route_maps': {name: [{'seq': int, 'action': 'permit' | 'deny',
'match': ['ip address prefix-list P', ...],
'set': ['local-preference 200', ...]}]},
'as_path_lists': {name: [(seq | None, action, regexp)]},
'community_lists': {name: [(seq | None, action, 'community ...')]}}
Area ids are normalised to dotted form (area 1 -> '0.0.0.1'). Both the
router-level passive-interface X and the interface-level ip ospf passive
syntaxes are recognised; interfaces[X]['passive'] reflects only the
interface-level statement, router_ospf['passive_interfaces'] the router-level one.
In router bgp the statements of the address-family ipv4 unicast block and the
router-level ones are merged (FRR moves network, next-hop-self, route-map...
into the address-family block in show running-config).
887def get_frr_running_config(grade: Grade0, machine_name: str, step: int = 1, allow_error: bool = False) -> Dict[str, Any]: 888 """Run 'vtysh -c "show running-config"' and parse it (see :func:`parse_frr_config`). 889 Returns {} on error or empty output.""" 890 output, code = _test(grade, machine_name, 'vtysh -c "show running-config"', step, allow_error) 891 if code != 0 or not output: 892 return {} 893 return parse_frr_config(output)
Run 'vtysh -c "show running-config"' and parse it (see parse_frr_config()).
Returns {} on error or empty output.
896def expected_auto_cost(reference_bandwidth: int, interface_bandwidth: int) -> int: 897 """OSPF cost FRR computes for an interface: ``round(reference / bandwidth)`` clamped to 898 ``[1, 65535]`` (both in Mbit/s; the default reference is 100000 and a veth reports 10000, 899 hence the default cost of 10).""" 900 if not interface_bandwidth: 901 return 10 902 cost = int(reference_bandwidth / interface_bandwidth + 0.5) 903 return max(1, min(65535, cost))
OSPF cost FRR computes for an interface: round(reference / bandwidth) clamped to
[1, 65535] (both in Mbit/s; the default reference is 100000 and a veth reports 10000,
hence the default cost of 10).
936def as_list_from_string(path) -> List[int]: 937 """``'65100 65000'`` -> ``[65100, 65000]`` (AS sets ``{65100,65000}`` keep their members).""" 938 if not path: 939 return [] 940 return [int(tok) for tok in re.findall(r'\d+', str(path))]
'65100 65000' -> [65100, 65000] (AS sets {65100,65000} keep their members).
945def parse_bgp_summary(data: Any) -> Dict[str, Any]: 946 """Parse the JSON of ``show bgp ipv4 unicast summary``. 947 948 Accepts the flat address-family object or the ``{'ipv4Unicast': {...}}`` wrapper of 949 ``show bgp summary json``. Returns ``{}`` when no BGP process answers, else:: 950 951 {'router_id': '10.0.0.1', 'as': 65001, 952 'peers': {'10.1.1.2': {'remote_as': 65100, 'state': 'Established', 'established': True, 953 'policy_blocked': False, # eBGP peer without in/out policy (RFC 8212) 954 'pfx_rcd': 3, 'pfx_snt': 1, 'description': 'ISP-A', 955 'uptime': '00:12:34'}}} 956 """ 957 if not isinstance(data, dict): 958 return {} 959 if 'ipv4Unicast' in data and isinstance(data['ipv4Unicast'], dict): 960 data = data['ipv4Unicast'] 961 if 'routerId' not in data and 'peers' not in data: 962 return {} 963 peers: Dict[str, Dict[str, Any]] = {} 964 for ip, p in (data.get('peers') or {}).items(): 965 if not isinstance(p, dict): 966 continue 967 state = p.get('state') 968 peers[ip] = { 969 'remote_as': _int_or_none(p.get('remoteAs')), 970 'state': state, 971 'established': state == 'Established', 972 'policy_blocked': p.get('peerState') == 'Policy', 973 'pfx_rcd': _int_or_none(p.get('pfxRcd')) or 0, 974 'pfx_snt': _int_or_none(p.get('pfxSnt')) or 0, 975 'description': p.get('desc'), 976 'uptime': p.get('peerUptime'), 977 } 978 return {'router_id': data.get('routerId'), 'as': _int_or_none(data.get('as')), 'peers': peers}
Parse the JSON of show bgp ipv4 unicast summary.
Accepts the flat address-family object or the {'ipv4Unicast': {...}} wrapper of
show bgp summary json. Returns {} when no BGP process answers, else::
{'router_id': '10.0.0.1', 'as': 65001,
'peers': {'10.1.1.2': {'remote_as': 65100, 'state': 'Established', 'established': True,
'policy_blocked': False, # eBGP peer without in/out policy (RFC 8212)
'pfx_rcd': 3, 'pfx_snt': 1, 'description': 'ISP-A',
'uptime': '00:12:34'}}}
981def get_bgp_summary(grade: Grade0, machine_name: str, step: int = 1, allow_error: bool = False) -> Dict[str, Any]: 982 """Run 'vtysh -c "show bgp ipv4 unicast summary json"' (see :func:`parse_bgp_summary`). 983 Returns {} when BGP is not running.""" 984 return parse_bgp_summary(_vtysh_json(grade, machine_name, 'show bgp ipv4 unicast summary', step, allow_error))
Run 'vtysh -c "show bgp ipv4 unicast summary json"' (see parse_bgp_summary()).
Returns {} when BGP is not running.
989def parse_bgp_neighbor(data: Any, peer: str = None) -> Dict[str, Any]: 990 """Parse the JSON of ``show bgp neighbors <peer>``. 991 992 The output is keyed by peer address; ``peer`` selects an entry (the first one when 993 omitted). Returns ``{}`` when the neighbour does not exist, else:: 994 995 {'peer': '10.1.1.2', 'remote_as': 65100, 'local_as': 65001, 'state': 'Established', 996 'established': True, 'link': 'external' | 'internal', 'description': 'ISP-A', 997 'remote_router_id': '100.100.100.100', 'local_router_id': '10.0.3.1', 998 'hold_time': 30, 'keepalive': 10, # negotiated, seconds 999 'configured_hold_time': 30, 'configured_keepalive': 10, 1000 'local_host': '10.0.3.1', 'foreign_host': '10.0.3.2', 'update_source': 'lo' | None, 1001 'accepted_prefixes': 3, 'sent_prefixes': 1, 1002 'route_reflector_client': False, 'next_hop_self': False, 'default_originate': False, 1003 'default_originate_route_map': None, 1004 'route_map_in': 'LP-IN', 'route_map_out': None, 'prefix_list_in': None, 'prefix_list_out': None, 1005 'send_community': True, 'af': {...ipv4Unicast...}, 'raw': {...}} 1006 """ 1007 if not isinstance(data, dict) or not data: 1008 return {} 1009 if 'bgpNoSuchNeighbor' in data or 'warning' in data: 1010 return {} 1011 if peer is not None: 1012 entry = data.get(str(peer)) 1013 else: 1014 entry = next((v for v in data.values() if isinstance(v, dict)), None) 1015 if not isinstance(entry, dict) or 'bgpState' not in entry: 1016 return {} 1017 af = ((entry.get('addressFamilyInfo') or {}).get('ipv4Unicast')) or {} 1018 if not isinstance(af, dict): 1019 af = {} 1020 peer_ip = peer if peer is not None else next(iter(data)) 1021 1022 def msecs(key) -> Optional[int]: 1023 v = _int_or_none(entry.get(key)) 1024 return None if v is None else v // 1000 1025 1026 if entry.get('nbrExternalLink'): 1027 link = 'external' 1028 elif entry.get('nbrInternalLink'): 1029 link = 'internal' 1030 else: 1031 link = None 1032 return { 1033 'peer': peer_ip, 1034 'remote_as': _int_or_none(entry.get('remoteAs')), 1035 'local_as': _int_or_none(entry.get('localAs')), 1036 'state': entry.get('bgpState'), 1037 'established': entry.get('bgpState') == 'Established', 1038 'link': link, 1039 'description': entry.get('nbrDesc'), 1040 'remote_router_id': entry.get('remoteRouterId'), 1041 'local_router_id': entry.get('localRouterId'), 1042 'hold_time': msecs('bgpTimerHoldTimeMsecs'), 1043 'keepalive': msecs('bgpTimerKeepAliveIntervalMsecs'), 1044 'configured_hold_time': msecs('bgpTimerConfiguredHoldTimeMsecs'), 1045 'configured_keepalive': msecs('bgpTimerConfiguredKeepAliveIntervalMsecs'), 1046 'local_host': entry.get('hostLocal'), 1047 'foreign_host': entry.get('hostForeign'), 1048 'update_source': entry.get('updateSource'), 1049 'accepted_prefixes': _int_or_none(af.get('acceptedPrefixCounter')) or 0, 1050 'sent_prefixes': _int_or_none(af.get('sentPrefixCounter')) or 0, 1051 'route_reflector_client': bool(af.get('routeReflectorClient')), 1052 'next_hop_self': bool(af.get('routerAlwaysNextHop') or af.get('nextHopSelf')), 1053 'default_originate': bool(af.get('defaultSent') or af.get('defaultNotSent')), 1054 'default_originate_route_map': af.get('defaultRouteMap'), 1055 'route_map_in': af.get('routeMapForIncomingAdvertisements'), 1056 'route_map_out': af.get('routeMapForOutgoingAdvertisements'), 1057 'prefix_list_in': af.get('incomingUpdatePrefixFilterList'), 1058 'prefix_list_out': af.get('outgoingUpdatePrefixFilterList'), 1059 'send_community': 'commAttriSentToNbr' in af if af else True, 1060 'af': af, 1061 'raw': entry, 1062 }
Parse the JSON of show bgp neighbors <peer>.
The output is keyed by peer address; peer selects an entry (the first one when
omitted). Returns {} when the neighbour does not exist, else::
{'peer': '10.1.1.2', 'remote_as': 65100, 'local_as': 65001, 'state': 'Established',
'established': True, 'link': 'external' | 'internal', 'description': 'ISP-A',
'remote_router_id': '100.100.100.100', 'local_router_id': '10.0.3.1',
'hold_time': 30, 'keepalive': 10, # negotiated, seconds
'configured_hold_time': 30, 'configured_keepalive': 10,
'local_host': '10.0.3.1', 'foreign_host': '10.0.3.2', 'update_source': 'lo' | None,
'accepted_prefixes': 3, 'sent_prefixes': 1,
'route_reflector_client': False, 'next_hop_self': False, 'default_originate': False,
'default_originate_route_map': None,
'route_map_in': 'LP-IN', 'route_map_out': None, 'prefix_list_in': None, 'prefix_list_out': None,
'send_community': True, 'af': {...ipv4Unicast...}, 'raw': {...}}
1065def get_bgp_neighbor(grade: Grade0, machine_name: str, peer, step: int = 1, allow_error: bool = False) -> Dict[str, Any]: 1066 """Run 'vtysh -c "show bgp neighbors <peer> json"' (see :func:`parse_bgp_neighbor`). 1067 Returns {} when BGP is not running or the neighbour is unknown.""" 1068 return parse_bgp_neighbor(_vtysh_json(grade, machine_name, f'show bgp neighbors {peer}', step, allow_error), 1069 peer=str(peer))
Run 'vtysh -c "show bgp neighbors parse_bgp_neighbor()).
Returns {} when BGP is not running or the neighbour is unknown.
1136def parse_bgp_routes(data: Any) -> Dict[str, List[Dict[str, Any]]]: 1137 """Parse the JSON of ``show bgp ipv4 unicast`` (the whole BGP table). 1138 1139 Returns ``{prefix: [path, ...]}`` (``{}`` when BGP is not running), each path being:: 1140 1141 {'valid': True, 'best': True, 'reason': 'Local Pref', 'path_from': 'internal', 1142 'nexthop': '10.0.3.3', 'nexthop_used': True, 'accessible': None, 1143 'peer': '10.0.3.2', 'peer_router_id': None, 'aspath': '65200 65000', 1144 'as_list': [65200, 65000], 'locprf': 200, 'med': 0, 'weight': 0, 'origin': 'IGP', 1145 'local': False, 'originator_id': None, 'cluster_list': [], 'communities': []} 1146 1147 A ``network`` statement whose prefix has no route in the RIB (``bgp network 1148 import-check``) yields a path with ``valid`` and ``best`` both ``False``. 1149 """ 1150 if not isinstance(data, dict): 1151 return {} 1152 routes = data.get('routes') 1153 if not isinstance(routes, dict): 1154 return {} 1155 result: Dict[str, List[Dict[str, Any]]] = {} 1156 for prefix, entries in routes.items(): 1157 if isinstance(entries, dict): 1158 entries = [entries] 1159 if not isinstance(entries, list): 1160 continue 1161 result[prefix] = [_parse_bgp_path_entry(e) for e in entries if isinstance(e, dict)] 1162 return result
Parse the JSON of show bgp ipv4 unicast (the whole BGP table).
Returns {prefix: [path, ...]} ({} when BGP is not running), each path being::
{'valid': True, 'best': True, 'reason': 'Local Pref', 'path_from': 'internal',
'nexthop': '10.0.3.3', 'nexthop_used': True, 'accessible': None,
'peer': '10.0.3.2', 'peer_router_id': None, 'aspath': '65200 65000',
'as_list': [65200, 65000], 'locprf': 200, 'med': 0, 'weight': 0, 'origin': 'IGP',
'local': False, 'originator_id': None, 'cluster_list': [], 'communities': []}
A network statement whose prefix has no route in the RIB (bgp network
import-check) yields a path with valid and best both False.
1165def get_bgp_routes(grade: Grade0, machine_name: str, step: int = 1, allow_error: bool = False) -> Dict[str, List[Dict[str, Any]]]: 1166 """Run 'vtysh -c "show bgp ipv4 unicast json"' (see :func:`parse_bgp_routes`). Returns {} on error.""" 1167 return parse_bgp_routes(_vtysh_json(grade, machine_name, 'show bgp ipv4 unicast', step, allow_error))
Run 'vtysh -c "show bgp ipv4 unicast json"' (see parse_bgp_routes()). Returns {} on error.
1170def bgp_best_path(routes: Dict[str, List[Dict[str, Any]]], prefix) -> Dict[str, Any]: 1171 """Return the best path of ``prefix`` in a :func:`parse_bgp_routes` result (``{}`` when none).""" 1172 for p in routes.get(str(prefix), []): 1173 if p.get('best'): 1174 return p 1175 return {}
Return the best path of prefix in a parse_bgp_routes() result ({} when none).
1178def bgp_paths(routes: Dict[str, List[Dict[str, Any]]], prefix, nexthop=None, peer=None, first_as: int = None, 1179 ends_with=None, path_from: str = None, valid: bool = None) -> List[Dict[str, Any]]: 1180 """Return the paths of ``prefix`` matching every given criterion (``ends_with`` is a 1181 list of AS numbers compared with the end of ``as_list``).""" 1182 result = [] 1183 for p in routes.get(str(prefix), []): 1184 if nexthop is not None and p.get('nexthop') != str(nexthop): 1185 continue 1186 if peer is not None and p.get('peer') != str(peer): 1187 continue 1188 if first_as is not None and (not p['as_list'] or p['as_list'][0] != int(first_as)): 1189 continue 1190 if ends_with is not None: 1191 tail = [int(a) for a in ends_with] 1192 if p['as_list'][-len(tail):] != tail: 1193 continue 1194 if path_from is not None and p.get('path_from') != path_from: 1195 continue 1196 if valid is not None and bool(p.get('valid')) != valid: 1197 continue 1198 result.append(p) 1199 return result
Return the paths of prefix matching every given criterion (ends_with is a
list of AS numbers compared with the end of as_list).
1204def parse_bgp_prefix(data: Any) -> Dict[str, Any]: 1205 """Parse the JSON of ``show bgp ipv4 unicast <prefix>`` (detailed paths). 1206 1207 Returns ``{'prefix': None, 'paths': []}`` when the prefix is unknown, else 1208 ``{'prefix': '10.1.0.0/24', 'paths': [path, ...]}`` with the fields of 1209 :func:`parse_bgp_routes` plus ``accessible``, ``peer_router_id``, ``originator_id``, 1210 ``cluster_list`` (route reflection) and ``communities`` (``['65001:300', ...]``). 1211 """ 1212 if not isinstance(data, dict) or not isinstance(data.get('paths'), list): 1213 return {'prefix': None, 'paths': []} 1214 return {'prefix': data.get('prefix'), 1215 'paths': [_parse_bgp_path_entry(e) for e in data['paths'] if isinstance(e, dict)]}
Parse the JSON of show bgp ipv4 unicast <prefix> (detailed paths).
Returns {'prefix': None, 'paths': []} when the prefix is unknown, else
{'prefix': '10.1.0.0/24', 'paths': [path, ...]} with the fields of
parse_bgp_routes() plus accessible, peer_router_id, originator_id,
cluster_list (route reflection) and communities (['65001:300', ...]).
1218def get_bgp_prefix(grade: Grade0, machine_name: str, prefix, step: int = 1, allow_error: bool = False) -> Dict[str, Any]: 1219 """Run 'vtysh -c "show bgp ipv4 unicast <prefix> json"' (see :func:`parse_bgp_prefix`).""" 1220 return parse_bgp_prefix(_vtysh_json(grade, machine_name, f'show bgp ipv4 unicast {prefix}', step, allow_error))
Run 'vtysh -c "show bgp ipv4 unicast parse_bgp_prefix()).
1225def parse_bgp_advertised_routes(data: Any) -> Dict[str, Dict[str, Any]]: 1226 """Parse the JSON of ``show bgp ipv4 unicast neighbors <peer> advertised-routes`` 1227 (the routes sent to that peer, after the outbound policy). 1228 1229 Returns ``{prefix: {'nexthop', 'aspath', 'as_list', 'locprf', 'med', 'weight', 'origin'}}`` 1230 (``{}`` on error or when nothing is advertised). 1231 """ 1232 if not isinstance(data, dict): 1233 return {} 1234 adv = data.get('advertisedRoutes') 1235 if not isinstance(adv, dict): 1236 return {} 1237 result: Dict[str, Dict[str, Any]] = {} 1238 for prefix, e in adv.items(): 1239 if not isinstance(e, dict): 1240 continue 1241 path = e.get('path') or '' 1242 result[prefix] = { 1243 'nexthop': e.get('nextHop'), 1244 'aspath': path, 1245 'as_list': as_list_from_string(path), 1246 'locprf': _int_or_none(e.get('locPrf')), 1247 'med': _int_or_none(e.get('metric')), 1248 'weight': _int_or_none(e.get('weight')) or 0, 1249 'origin': e.get('bgpOriginCode') or e.get('origin'), 1250 } 1251 return result
Parse the JSON of show bgp ipv4 unicast neighbors <peer> advertised-routes
(the routes sent to that peer, after the outbound policy).
Returns {prefix: {'nexthop', 'aspath', 'as_list', 'locprf', 'med', 'weight', 'origin'}}
({} on error or when nothing is advertised).
1254def get_bgp_advertised_routes(grade: Grade0, machine_name: str, peer, step: int = 1, 1255 allow_error: bool = False) -> Dict[str, Dict[str, Any]]: 1256 """Run 'vtysh -c "show bgp ipv4 unicast neighbors <peer> advertised-routes json"' 1257 (see :func:`parse_bgp_advertised_routes`).""" 1258 return parse_bgp_advertised_routes( 1259 _vtysh_json(grade, machine_name, f'show bgp ipv4 unicast neighbors {peer} advertised-routes', step, allow_error))
Run 'vtysh -c "show bgp ipv4 unicast neighbors parse_bgp_advertised_routes()).
1284def parse_rip_routes(output: str) -> Dict[str, List[Dict[str, Any]]]: 1285 """Parse ``show ip rip`` (the RIP table of ripd, not the kernel one). 1286 1287 Returns ``{prefix: [path, ...]}`` — one path per row, an ECMP route has several — with 1288 ``code`` (``'R'`` RIP, ``'C'`` connected, ``'S'`` static, ``'K'`` kernel...), ``sub_code`` 1289 (:data:`RIP_SUB_CODES`: ``'n'`` learned from a neighbour, ``'i'`` interface, ``'r'`` 1290 redistributed, ``'d'`` default, ``'s'`` static RIP route), ``nexthop`` (``None`` for a local 1291 route), ``metric``, ``source`` (the neighbour the route was learned from, ``None`` for the 1292 router's own routes), ``external`` (``'static:20'`` of ``self (static:20)``), ``tag`` and 1293 ``time`` (the timeout or garbage-collection timer, ``None`` when the route has none). 1294 ``{}`` when ripd has no instance (``% RIP instance not found``) or on the placeholder. 1295 """ 1296 result: Dict[str, List[Dict[str, Any]]] = {} 1297 for line in (output or '').splitlines(): 1298 m = _RIP_ROUTE_RE.match(line) 1299 if not m: 1300 continue 1301 nexthop = m.group('nexthop') 1302 source = m.group('source') 1303 result.setdefault(m.group('prefix'), []).append({ 1304 'code': m.group('code'), 1305 'sub_code': m.group('sub'), 1306 'nexthop': None if nexthop == '0.0.0.0' else nexthop, 1307 'metric': int(m.group('metric')), 1308 'source': None if source.startswith('self') else source, 1309 'external': m.group('external'), 1310 'tag': int(m.group('tag')), 1311 'time': m.group('time'), 1312 }) 1313 return result
Parse show ip rip (the RIP table of ripd, not the kernel one).
Returns {prefix: [path, ...]} — one path per row, an ECMP route has several — with
code ('R' RIP, 'C' connected, 'S' static, 'K' kernel...), sub_code
(RIP_SUB_CODES: 'n' learned from a neighbour, 'i' interface, 'r'
redistributed, 'd' default, 's' static RIP route), nexthop (None for a local
route), metric, source (the neighbour the route was learned from, None for the
router's own routes), external ('static:20' of self (static:20)), tag and
time (the timeout or garbage-collection timer, None when the route has none).
{} when ripd has no instance (% RIP instance not found) or on the placeholder.
1316def get_rip_routes(grade: Grade0, machine_name: str, step: int = 1, 1317 allow_error: bool = False) -> Dict[str, List[Dict[str, Any]]]: 1318 """Run 'vtysh -c "show ip rip"' and parse it (see :func:`parse_rip_routes`). ``{}`` on error.""" 1319 output, code = _test(grade, machine_name, 'vtysh -c "show ip rip"', step, allow_error) 1320 if code != 0 or not output: 1321 return {} 1322 return parse_rip_routes(output)
Run 'vtysh -c "show ip rip"' and parse it (see parse_rip_routes()). {} on error.
1325def rip_route_metric(routes: Dict[str, List[Dict[str, Any]]], prefix, learned: bool = None) -> Optional[int]: 1326 """Smallest metric of *prefix* in a :func:`parse_rip_routes` result, ``None`` when the prefix 1327 is absent. ``learned=True`` only looks at the paths learned from a neighbour (code ``R``, 1328 sub-code ``n``), ``learned=False`` at the router's own ones (interface, redistributed...).""" 1329 metrics = [p['metric'] for p in routes.get(str(prefix), []) 1330 if learned is None or learned == (p['source'] is not None)] 1331 return min(metrics) if metrics else None
Smallest metric of prefix in a parse_rip_routes() result, None when the prefix
is absent. learned=True only looks at the paths learned from a neighbour (code R,
sub-code n), learned=False at the router's own ones (interface, redistributed...).
1334def rip_route_sources(routes: Dict[str, List[Dict[str, Any]]], prefix) -> List[str]: 1335 """Neighbours *prefix* was learned from in a :func:`parse_rip_routes` result (reachable 1336 paths only: a route in garbage collection, metric 16, is left out).""" 1337 return [p['source'] for p in routes.get(str(prefix), []) 1338 if p['source'] is not None and p['metric'] < RIP_INFINITY]
Neighbours prefix was learned from in a parse_rip_routes() result (reachable
paths only: a route in garbage collection, metric 16, is left out).
1341def parse_rip_status(output: str) -> Dict[str, Any]: 1342 """Parse ``show ip rip status``. 1343 1344 Returns ``{'update', 'timeout', 'garbage'}`` (the three timers of ``timers basic``, seconds), 1345 ``default_metric``, ``redistribute`` (``['connected', 'static']``), ``send_version`` / 1346 ``receive_version`` (``'1'``, ``'2'``, ``'1 2'``), ``interfaces`` (``{name: {'send', 1347 'receive', 'key_chain'}}``: the interfaces RIP runs on, passive ones included), ``networks`` 1348 (the ``network`` statements: prefixes and interface names, then the ``neighbor`` addresses), 1349 ``passive_interfaces``, ``sources`` (``{gateway: {'bad_packets', 'bad_routes', 'distance', 1350 'last_update'}}``: every neighbour a packet was received from) and ``distance``. 1351 ``{}`` when ripd has no instance or on the placeholder. 1352 """ 1353 text = output or '' 1354 if 'Routing Protocol is "rip"' not in text: 1355 return {} 1356 status: Dict[str, Any] = { 1357 'update': None, 'timeout': None, 'garbage': None, 'default_metric': None, 'redistribute': [], 1358 'send_version': None, 'receive_version': None, 'interfaces': {}, 'networks': [], 1359 'passive_interfaces': [], 'sources': {}, 'distance': None, 1360 } 1361 section = None 1362 for line in text.splitlines(): 1363 stripped = line.strip() 1364 if not stripped: 1365 continue 1366 m = re.match(r'Sending updates every (\d+) seconds', stripped) 1367 if m: 1368 status['update'] = int(m.group(1)) 1369 continue 1370 m = re.match(r'Timeout after (\d+) seconds, garbage collect after (\d+) seconds', stripped) 1371 if m: 1372 status['timeout'], status['garbage'] = int(m.group(1)), int(m.group(2)) 1373 continue 1374 m = re.match(r'Default redistribution metric is (\d+)', stripped) 1375 if m: 1376 status['default_metric'] = int(m.group(1)) 1377 continue 1378 if stripped.startswith('Redistributing:'): 1379 status['redistribute'] = stripped[len('Redistributing:'):].split() 1380 continue 1381 m = re.match(r'Default version control: send version (.+?), receive (any version|version (.+?))\s*$', 1382 stripped) 1383 if m: 1384 status['send_version'] = m.group(1).strip() 1385 status['receive_version'] = '1 2' if m.group(2) == 'any version' else m.group(3).strip() 1386 continue 1387 m = re.match(r'Distance: \(default is (\d+)\)', stripped) 1388 if m: 1389 status['distance'] = int(m.group(1)) 1390 section = None 1391 continue 1392 if re.match(r'Interface\s+Send\s+Recv', stripped): 1393 section = 'interfaces' 1394 continue 1395 if stripped == 'Routing for Networks:': 1396 section = 'networks' 1397 continue 1398 if stripped == 'Passive Interface(s):': 1399 section = 'passive' 1400 continue 1401 if stripped == 'Routing Information Sources:': 1402 section = 'sources' 1403 continue 1404 if section == 'interfaces': 1405 m = _RIP_INTERFACE_RE.match(line) 1406 if m: 1407 status['interfaces'][m.group('name')] = { 1408 'send': m.group('send'), 'receive': m.group('receive'), 'key_chain': m.group('key_chain')} 1409 elif section == 'networks': 1410 status['networks'].append(stripped) 1411 elif section == 'passive': 1412 status['passive_interfaces'].append(stripped) 1413 elif section == 'sources': 1414 m = _RIP_SOURCE_RE.match(line) 1415 if m: 1416 status['sources'][m.group('gateway')] = { 1417 'bad_packets': int(m.group('bad_packets')), 'bad_routes': int(m.group('bad_routes')), 1418 'distance': int(m.group('distance')), 'last_update': m.group('last_update')} 1419 return status
Parse show ip rip status.
Returns {'update', 'timeout', 'garbage'} (the three timers of timers basic, seconds),
default_metric, redistribute (['connected', 'static']), send_version /
receive_version ('1', '2', '1 2'), interfaces ({name: {'send',
'receive', 'key_chain'}}: the interfaces RIP runs on, passive ones included), networks
(the network statements: prefixes and interface names, then the neighbor addresses),
passive_interfaces, sources ({gateway: {'bad_packets', 'bad_routes', 'distance',
'last_update'}}: every neighbour a packet was received from) and distance.
{} when ripd has no instance or on the placeholder.
1422def get_rip_status(grade: Grade0, machine_name: str, step: int = 1, allow_error: bool = False) -> Dict[str, Any]: 1423 """Run 'vtysh -c "show ip rip status"' and parse it (see :func:`parse_rip_status`). 1424 ``{}`` on error or when RIP is not configured.""" 1425 output, code = _test(grade, machine_name, 'vtysh -c "show ip rip status"', step, allow_error) 1426 if code != 0 or not output: 1427 return {} 1428 return parse_rip_status(output)
Run 'vtysh -c "show ip rip status"' and parse it (see parse_rip_status()).
{} on error or when RIP is not configured.