diff --git a/birdvisu/providers.py b/birdvisu/providers.py new file mode 100644 index 0000000..b9dabed --- /dev/null +++ b/birdvisu/providers.py @@ -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 +