Reimplement TopologyProvider core
At this moment it no actual providers are present, but the parsing of ospffile is the bigger part of providing topologies. Maybe this file is not the good place for that code.styling
parent
460738b84f
commit
e80a4e5424
@ -0,0 +1,398 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from collections import defaultdict
|
||||
from typing import Final
|
||||
from enum import Enum
|
||||
from .topo_v3 import TopologyV3, VertexID, Edge, Vertex, VertexType, MetricType
|
||||
from ipaddress import IPv4Network, IPv6Network, ip_network
|
||||
from socket import AF_INET, AF_INET6
|
||||
from . import ospffile
|
||||
|
||||
class TopologyProvider(ABC):
|
||||
"""Allows obtaining topology description from somewhere
|
||||
|
||||
We only require a function to get a topology. However, the topology
|
||||
retrieval may require further specification, e.g. for which area the
|
||||
topology should be returned. Handling of this is an implementation detail
|
||||
of the specific provider."""
|
||||
|
||||
@abstractmethod
|
||||
def get_topology(self) -> TopologyV3: ...
|
||||
|
||||
class OspfFileParseError(Exception): pass
|
||||
|
||||
class OspfFileTopologyParser:
|
||||
"""This class parses an ospffile-formated data into a Topology. However,
|
||||
this is not a full-fledged provider, because it does not deal with
|
||||
retrieval of the file itself.
|
||||
|
||||
Since the ospf dump is intended for a knowledgeable human, there are a few
|
||||
caveats with parsing it:
|
||||
- The dump may describe more than one area
|
||||
- It is not clear whether we are dealing with OSPFv2 or OSPFv3.
|
||||
- The precise format is not documented. We tried simulating various
|
||||
network conditions to see as many different examples as possible and
|
||||
looked briefly into ``proto/ospf/ospf.c`` in BIRD's source code, but
|
||||
still are not sure.
|
||||
|
||||
Therefore either the initialiser is told, which version we are dealing
|
||||
with, or we try to guess, based on network directive. That seems to be the
|
||||
only distinguishing part."""
|
||||
|
||||
# We aim to have most of the code common, so only a few functions are
|
||||
# specialised for each OSPF version.
|
||||
|
||||
# Convention: when we pass the resulting topology as parameter, it is
|
||||
# always the first parameter after self.
|
||||
|
||||
def __init__(self, version=None, area=None):
|
||||
if version not in [2,3,None]: raise ValueError("Unknown OSPF version")
|
||||
if isinstance(area, int) or area is None:
|
||||
self.area = area
|
||||
else:
|
||||
self.area = self.parse_fake_ip(area)
|
||||
self.version = version
|
||||
self.guessing = version is None
|
||||
|
||||
@staticmethod
|
||||
def parse_fake_ip(s:str):
|
||||
# Only import here, so that if I accidentally write IPv4Address
|
||||
# elsewhere, it fails.
|
||||
from ipaddress import IPv4Address
|
||||
return int(IPv4Address(s))
|
||||
|
||||
def parse(self, data: str, freeze=True) -> TopologyV3:
|
||||
"""Parses the ospffile (duh!)
|
||||
|
||||
Expected format::
|
||||
BIRD whatever greeting ...
|
||||
area ...
|
||||
router ...
|
||||
neigh ...
|
||||
...
|
||||
...
|
||||
network ...
|
||||
address ...
|
||||
...
|
||||
...
|
||||
|
||||
We consider layers of the tree: the layer with ``area`` is the top
|
||||
level, the one with ``router``s and ``network``s is the level-2, and
|
||||
the specifics are level-3. The hopefully nice thing about ospffile is
|
||||
that the directed edges only go from l2 to l3 directives.
|
||||
|
||||
Unfortunately, when adding an edge, we must make sure that a correct
|
||||
vertex is referenced. That is not a problem for anything external
|
||||
(external networks, stubnets, xrouters, xnetworks, …) nor for routers,
|
||||
since for all of this a complete :class:VertexID can be created.
|
||||
However, transit networks in OSPFv2 may be distinguished by the DR,
|
||||
which is not contained in l3 directives. In that case, we postpone
|
||||
adding the edge and only add it when all network vertices are present
|
||||
in the topology, so that we can take the DR RID from that.
|
||||
|
||||
Even more unfortunate consequence of that is that the ospffile does
|
||||
*not* contain enough information for us to pick the correct network
|
||||
vertex in case of network split (since we only know IP range which is
|
||||
shared). While the topology will be inaccurate, we can still detect
|
||||
that a split has occured, so the user can be made aware of this
|
||||
problem. (Also, the forwarding itself is as confused as us in this
|
||||
case.)
|
||||
|
||||
:param freeze: Whether to freeze the resulting topology."""
|
||||
syntree = ospffile.loads(data)
|
||||
result = TopologyV3()
|
||||
|
||||
# Check for correct areas: either the specified area must be present,
|
||||
# or there must only be a single area in the data.
|
||||
found_area = None
|
||||
|
||||
for line, children in syntree:
|
||||
# Let the following line be an idiom for parsing ospffile:
|
||||
tag, *details = line.split()
|
||||
# Possible values: BIRD (from greeting), area.
|
||||
if tag == 'BIRD': continue
|
||||
if tag != 'area': raise OspfFileParseError(f'Unknown top-level tag: {tag}')
|
||||
area = self.parse_fake_ip(details[0])
|
||||
if self.area is not None and area != self.area: continue
|
||||
if self.area is None and found_area is not None:
|
||||
raise OspfFileParseError('Too many areas provided')
|
||||
found_area = area
|
||||
|
||||
# We think we have correct area. parse it.
|
||||
self.parse_area(result, children)
|
||||
|
||||
# no break here, because we might encounter another area, in which
|
||||
# case we raise an error.
|
||||
|
||||
# Final check: we should have found at least one vertex, else this was
|
||||
# not a good file (likely cause: the specified area was not present)
|
||||
if len(result.vertices) == 0:
|
||||
if self.area is not None and found_area is not None:
|
||||
raise OspfFileParseError('Parsed empty topology, refusing to continue. (Maybe bad area?)')
|
||||
raise OspfFileParseError('Parsed empty topology, refusing to continue.')
|
||||
|
||||
if freeze: result.freeze()
|
||||
return result
|
||||
|
||||
def parse_area(self, result, syntree):
|
||||
self.future_transit_networks_edges: list[tuple[VertexID, str, int, int]] = [] # source, target, cost, count
|
||||
for line, children in syntree:
|
||||
tag, *details = line.split()
|
||||
if tag == 'router':
|
||||
vertex = self.add_vertex_for_router(result, details)
|
||||
self.parse_vertex(result, children, vertex)
|
||||
elif tag == 'network':
|
||||
# This is trickier, since the details for the vertex may be
|
||||
# hidden in the children. Therefore, we offload this to a
|
||||
# dedicated function.
|
||||
self.parse_l2_network(result, line, children)
|
||||
else:
|
||||
raise OspfFileParseError(f'Unknown tag in area: {tag}')
|
||||
# Process postponed edges. At this point, all the vertices should
|
||||
# already be created, so the only issue is resolving the correct
|
||||
# VertexIDs for the networks.
|
||||
# Since these are networks, the process is generally different for each version of OSPF
|
||||
if self.version is None:
|
||||
# TODO: logger?
|
||||
print('WARNING: At this point the OSPF version should have been guessed!')
|
||||
# We need some sort of directive for this. We make one up.
|
||||
directive = 'network ' + self.future_transit_networks_edges[0][1]
|
||||
self.guess_version(directive)
|
||||
if self.version == 2: return self.add_transit_network_edges_ospfv2(result)
|
||||
if self.version == 3: return self.add_transit_network_edges_ospfv3(result)
|
||||
raise RuntimeError('Bug? Unreachable point of code.')
|
||||
|
||||
def add_transit_network_edges_ospfv2(self, result):
|
||||
for src, tgt, cost, count in self.future_transit_networks_edges:
|
||||
# tgt may under some circumstances be a DR RID in brackets. We have
|
||||
# not seen that, so not implementing.
|
||||
if tgt.startswith(r'['): raise NotImplementedError('A network is identified by DR, not by range.')
|
||||
tgt_addr = IPv4Network(tgt)
|
||||
# We do not have a VertexFinder available at the moment. TODO?
|
||||
candidates = list(filter(lambda v: v.type == VertexType.TransitNet and v.id.address == tgt_addr, result.vertices.values()))
|
||||
found = 0
|
||||
for cand in candidates:
|
||||
# We match by the network being connected back to the router
|
||||
for edge in cand.outgoing_edges:
|
||||
if edge.target == src:
|
||||
if found == 0:
|
||||
# This is the first occurence, so use this
|
||||
edge = Edge(source=src, target=cand.id, cost=cost, count=count)
|
||||
result.add_edge(edge)
|
||||
found += 1
|
||||
if found == 0:
|
||||
raise OspfFileParseError(f'Could not find a network {tgt_addr} to connect {src.router_id} to.')
|
||||
if found > 1:
|
||||
print(f'WARNING: Multiple candidates for connecting {src.router_id} to {tgt_addr}')
|
||||
|
||||
def add_transit_network_edges_ospfv3(self, result):
|
||||
for src, tgt, cost, count in self.future_transit_networks_edges:
|
||||
# tgt is a DR RID + iface ID in brackets. This pair should uniquely
|
||||
# determine the network. (We are dealing with a single topology, so
|
||||
# it must match.)
|
||||
dr_id, discr = tgt.lstrip(r'[').rstrip(']').split('-')
|
||||
candidates = list(filter(lambda vid: vid.dr_id == dr_id and vid.discriminator == discr, result.vertices))
|
||||
if len(candidates) != 1: raise OspfFileParseError(f'Multiple candidates for uniquely determined network {tgt}')
|
||||
tgtid = candidates[0]
|
||||
edge = Edge(source=src, target=tgtid, cost=cost, count=count)
|
||||
result.add_edge(edge)
|
||||
|
||||
|
||||
def parse_l2_network(self, result, directive, children):
|
||||
# We need to split the children into at least two groups: those
|
||||
# describing incident edges and those specifying network details
|
||||
detail_lines = []
|
||||
edge_lines = []
|
||||
for line, chld in children:
|
||||
tag, *rest = line.split()
|
||||
if tag in ['distance', 'unreachable']: continue
|
||||
if tag in ['dr', 'address']: detail_lines.append((line, chld))
|
||||
elif tag in ['router']: edge_lines.append((line, chld))
|
||||
else: raise OspfFileParseError(f'Unknown tag for network: {tag}')
|
||||
vtx = self.get_vertex_for_l2_network(directive, detail_lines)
|
||||
result.add_vertex(vtx)
|
||||
own_id = vtx.id
|
||||
|
||||
# Add the edges
|
||||
# The neighbours may only be routers for transit networks, so we can
|
||||
# add them right away.
|
||||
# In order to count matching lines, we note the edges and create them in bulk.
|
||||
future_edges: dict[VertexID, int] = defaultdict(lambda: 0)
|
||||
for line, chld in edge_lines:
|
||||
if chld != []: raise OspfFileParseError('Unsupported level-4 directive')
|
||||
# TODO: If BIRD ever starts supporting RFC 8042 (OSPF Two-Part
|
||||
# Metric), this is likely to also include the metric. At that
|
||||
# moment this will crash and only then will we implement that
|
||||
# feature. (We could guess the format right away, but that would
|
||||
# only be a guess.)
|
||||
tag, router = line.split
|
||||
rid = self.parse_fake_ip(router)
|
||||
peer_id = VertexID(family=None, address=None, is_router=True, router_id=rid, dr_id=None, discriminator=None)
|
||||
future_edges[peer_id] += 1
|
||||
for peer, count in future_edges.items():
|
||||
edge = Edge(source=own_id, target=peer, cost=0, count=count)
|
||||
result.add_edge(edge)
|
||||
|
||||
def parse_vertex(self, result, syntree, vertex):
|
||||
own_id = vertex.id
|
||||
# We know that syntree only contains irrelevant details
|
||||
# (distance/unreachable) and edges to other vertices. The only problem
|
||||
# is resolving transit networks, since syntree does not contain DRs
|
||||
# when dealing with OSPFv2.
|
||||
T = VertexType
|
||||
neigh_types: Final[dict[str, tuple[VertexType, bool]]] = {
|
||||
# We map not only the type of vertex, but also whether the edge is virtual (vlinks only)
|
||||
'vlink': (T.Router, True),
|
||||
'xnetwork': (T.ExtraAreaNet, False),
|
||||
'xrouter': (T.ExtraAreaRouter, False),
|
||||
'stubnet': (T.StubNet, False),
|
||||
'external': (T.ExternalNet, False),
|
||||
'router': (T.Router, False),
|
||||
'network': (T.TransitNet, False),
|
||||
}
|
||||
# As mentioned, we can create most of the edges right away, but not
|
||||
# transit networks. (Stubnets are fine, since we know our RID.)
|
||||
# Same as for networks: we must create the edges in bulk at the end,
|
||||
# since there may be multiedges.
|
||||
future_edges: dict[tuple[VertexID, int, MetricType, bool], int] = defaultdict(lambda: 0)
|
||||
# mypy 1.3.0 wtf.
|
||||
future_transit_networks = dict[tuple[str, int], int] = defaultdict(lambda: 0)
|
||||
for line, chld in syntree:
|
||||
tag, *det = line.split()
|
||||
if tag in ['distance', 'unreachable']: continue
|
||||
if tag not in neigh_types:
|
||||
raise OspfFileParseError(f'Unknown tag {tag} for router {vertex.id.router_id}')
|
||||
if chld != []: raise OspfFileParseError('Unsupported level-4 directive')
|
||||
# All of the lines seem to follow the same format
|
||||
tgt, mtype, cost = det
|
||||
try:
|
||||
mtype = {'metric': MetricType.Type1, 'metric2': MetricType.Type2}[mtype]
|
||||
except KeyError as e:
|
||||
raise OspfFileParseError(f'Unknown metric type {e.args}') from e
|
||||
cost = int(cost)
|
||||
ntype, isvlink = neigh_types[tag]
|
||||
if ntype in [T.Router, T.ExtraAreaRouter]:
|
||||
rid = self.parse_fake_ip(tgt)
|
||||
target_id = VertexID(family=None, address=None, is_router=True, router_id=rid, dr_id=None, discriminator=None)
|
||||
if target_id not in result.vertices:
|
||||
vtx = Vertex(id=target_id, topology=None, type=ntype)
|
||||
result.add_vertex(vtx)
|
||||
elif result.vertices[target_id].type != ntype:
|
||||
# FIXME: should check using add_vertex_for_router?
|
||||
raise OspfFileParseError(f'The router {target_id.router_id} is both in area and external to it.')
|
||||
future_edges[(target_id, cost, mtype, isvlink)] += 1
|
||||
continue
|
||||
if ntype in [T.ExtraAreaNet, T.ExternalNet, T.StubNet]:
|
||||
rid = own_id.router_id if ntype == T.StubNet else None
|
||||
addr = ip_network(tgt)
|
||||
if isinstance(addr, IPv4Network):
|
||||
family = AF_INET
|
||||
elif isinstance(addr, IPv6Network):
|
||||
family = AF_INET6
|
||||
else:
|
||||
raise OspfFileParseError(f'Unknown family of address {addr} in edge from {own_id.router_id}')
|
||||
target_id = VertexID(family=family, address=addr, is_router=False, router_id=rid, dr_id=None, discriminator=None)
|
||||
if target_id not in result.vertices:
|
||||
vtx = Vertex(id=target_id, topology=None, type=ntype)
|
||||
result.add_vertex(vtx)
|
||||
future_edges[(target_id, cost, mtype, isvlink)] += 1
|
||||
continue
|
||||
if ntype == T.TransitNet: # Always true at this point, just for formatting
|
||||
# Deal with this later.
|
||||
future_transit_networks[(tgt, cost)] += 1
|
||||
continue
|
||||
# Bulk edge creation:
|
||||
for details, count in future_edges.items():
|
||||
peer_id, cost, mtype, isvirt = details
|
||||
edge = Edge(source=own_id, target=peer_id, cost=cost, count=count, metric_type=mtype, virtual=isvirt)
|
||||
result.add_edge(edge)
|
||||
for details, count in future_transit_networks:
|
||||
tgt, cost = details
|
||||
self.future_transit_networks_edges.append((own_id, tgt, cost, count))
|
||||
|
||||
def get_vertex_for_l2_network(self, directive, details):
|
||||
if self.version is None: self.guess_version(directive)
|
||||
if self.version == 2: return self.get_vertex_for_l2_network_ospfv2(directive, details)
|
||||
if self.version == 3: return self.get_vertex_for_l2_network_ospfv3(directive, details)
|
||||
|
||||
def guess_version(self, directive):
|
||||
"""Version guesser, usable for both l2 and l3 directives."""
|
||||
if self.version is not None: raise RuntimeError('Bug? Trying to guess known OSPF version.')
|
||||
if not self.guessing: raise RuntimeError('Bug? Inconsistency about guessing in OspfFileTopologyParser.')
|
||||
tag, ident, *rest_maybe = directive.split()
|
||||
if tag != 'network': raise RuntimeError('Bug? Guessing from a non-network. (No tasseography here.)')
|
||||
# For OSPFv2, networks can be identified by DR RID on l3, but it will
|
||||
# not contain iface ID. (Ref: BIRD source @ ecbae010 proto/ospf/ospf.c:1084)
|
||||
self.version = 3 if ident.contains(r'-') else 2
|
||||
|
||||
def get_vertex_for_l2_network_ospfv3(self, directive, details):
|
||||
_tag, ident = directive.split()
|
||||
# In OSPFv3, the ID is always '[DRID-iface]'
|
||||
dr, discr = directive.rstrip(r'[').rstrip(r']').split(r'-')
|
||||
dr = self.parse_fake_ip(dr)
|
||||
addresses = []
|
||||
for line, chld in details:
|
||||
if chld != []: raise OspfFileParseError('Unsupported level-4 directive')
|
||||
tag, addr = line.split()
|
||||
if tag != 'address':
|
||||
raise OspfFileParseError(
|
||||
'Bad ospffile: mixing OSPFv2 directive in OSPFv3 file.'
|
||||
+ (' Maybe bad guess?' if self.guessing else ''))
|
||||
addresses.append(ip_network(addr))
|
||||
if len(addresses) == 0:
|
||||
final_addr = None
|
||||
family = None
|
||||
elif len(addresses) == 1:
|
||||
final_addr = addresses[0]
|
||||
if isinstance(final_addr, IPv4Network):
|
||||
family = AF_INET
|
||||
elif isinstance(final_addr, IPv6Network):
|
||||
family = AF_INET6
|
||||
else:
|
||||
raise OspfFileParseError(f'Unknown family of address {final_addr} in network {directive}')
|
||||
else: # Multiple addresses
|
||||
# mypy 1.3.0 wtf.
|
||||
final_addr = tuple(addresses)
|
||||
if all(isinstance(a, IPv4Network) for a in final_addr):
|
||||
family = AF_INET
|
||||
elif all(isinstance(a, IPv6Network) for a in final_addr):
|
||||
family = AF_INET6
|
||||
else:
|
||||
raise OspfFileParseError(f'Multiple or unknown families for network {directive}')
|
||||
|
||||
vtxid = VertexID(family=family, is_router=False, address=final_addr, router_id=None, dr_id = dr, discriminator=discr)
|
||||
vtx = Vertex(id=vtxid, topology=None, type=VertexType.TransitNet)
|
||||
return vtx
|
||||
|
||||
def get_vertex_for_l2_network_ospfv2(self, directive, details):
|
||||
_tag, ident = directive.split()
|
||||
network = IPv4Network(ident)
|
||||
dr = None
|
||||
for line, chld in details:
|
||||
if chld != []: raise OspfFileParseError('Unsupported level-4 directive')
|
||||
tag, drid = line.split()
|
||||
if tag != 'dr':
|
||||
raise OspfFileParseError(
|
||||
'Bad ospffile: mixing OSPFv3 directive in OSPFv2 file.'
|
||||
+ (' Maybe bad guess?' if self.guessing else ''))
|
||||
if dr is not None:
|
||||
raise OspfFileParseError(f'Multiple DRs for network {directive}')
|
||||
dr = self.parse_fake_ip(drid)
|
||||
|
||||
vtxid = VertexID(family=AF_INET, is_router=False, address=network, router_id=None, dr_id=dr, discriminator=None)
|
||||
vtx = Vertex(id=vtxid, topology=None, type=VertexType.TransitNet)
|
||||
return vtx
|
||||
|
||||
def add_vertex_for_router(self, result, details, extraarea=False):
|
||||
# FIXME: Should be reused for l3 directive, but not possible atm
|
||||
type = VertexType.Router if not extraarea else VertexType.ExtraAreaRouter
|
||||
router_id = self.parse_fake_ip(details[0])
|
||||
vtxid = VertexID(family=None, is_router=True, address=None, router_id=router_id, dr_id=None, discriminator=None)
|
||||
vtx = Vertex(id=vtxid, topology=None, type=type)
|
||||
# If the router is already present, check consistency (It is not wrong
|
||||
# to add it multiple times, it should be correctly substituted.)
|
||||
if vtxid in result.vertices:
|
||||
if vtx.type != result.vertices[vtxid].type:
|
||||
raise OspfFileParseError(f'Inconsistent type for router {vtxid.router_id}')
|
||||
result.add_vertex(vtx)
|
||||
return vtx
|
||||
|
Loading…
Reference in New Issue