From 610198ea6bee142e5a25a3afb41d19516b47b4f8 Mon Sep 17 00:00:00 2001 From: Pavel 'LEdoian' Turinsky Date: Sun, 9 Jul 2023 07:16:45 +0200 Subject: [PATCH] Add topology v3 This is a re-implemented topology, which should be more compatible with OSPF's idea of a network system. --- birdvisu/topo_v3.py | 313 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 313 insertions(+) create mode 100644 birdvisu/topo_v3.py diff --git a/birdvisu/topo_v3.py b/birdvisu/topo_v3.py new file mode 100644 index 0000000..0b37175 --- /dev/null +++ b/birdvisu/topo_v3.py @@ -0,0 +1,313 @@ +"""Topology, version 3 + +We now adhere to the RFCs (mostly RFC 2328) and represent the topology in the +same way, that is, as a directed multigraph on the union of all the networks and routers. +""" + +from dataclasses import dataclass, field +from enum import IntEnum +from socket import AddressFamily +from collections import defaultdict +from ipaddress import IPv4Network, IPv6Network + +class TopologyV3: + """This class represents the topology, that is the graph itself. + + We implement a DAG of ancestors, so that a joined topology can be created. + The class :class:VertexFinder is used to keep track about which vertices + are present in which topologies. The ancestor topologies are named, so that + they are easy to distinguish. At present, we expect that the only use of + this mechanism is to join a "reference" topology to the "current" one, + using these exact string to name the ancestors. However, the mechanism is + not restricted to this use case. + + Once a topology is an ancestor to another, it must not be changed. + Therefore, we implement a :attr:frozen flag, which specifies exactly + that. Therefore, the topology must only be modified using the methods, not + directly; all the methods check whether the topology is frozen. (Since this + is Python, it would be impractical to make the frozen topology actually + immutable. We trust the developers to be cooperative and not try to fix + this until the current state is shown to be prone to accidental + modification.) + + Vertices are stored in a dictionary by their :class:`VertexID`'s, so that + we can quickly both iterate over them and check their presence. The + :class:Vertex objects are not hashable.""" + def __init__(self): + self.frozen = False + self.ancestors: dict[str, TopologyV3] = dict() + self.vertices: dict[VertexID, Vertex] = dict() + self.edges: set[Edge] = set() + + def freeze(self): + self.frozen = True + + def add_ancestor(self, label, ancestor): + if label in self.ancestors: raise KeyError('Ancestor with this label already present') + if not ancestor.frozen: raise ValueError('Ancestor must be a frozen topology') + self.ancestors[label] = ancestor + for v in ancestor.vertices.values(): + self.add_vertex(v) + for e in ancestor.edges: + self.add_edge(e) + def add_vertex(self, vertex): + """When adding a vertex, the original vertex may already be present. In + that case, we check if the details match, and if not, we unset the + specific detail. (Currently, only detail to be checked is the type, + since when the ID differs, we create two vertices). + + When adding a vertex from different topology, we must not modify it, so + we create our own copy.""" + if self.frozen: raise ValueError('Cannot add vertex to frozen topology.') + if vertex.topology is not None and vertex.topology is not self: + new_vertex = Vertex(id=vertex.id, topology=self, type=vertex.type) + else: + # Not a vertex from different topology, so we can reuse. + new_vertex = vertex + new_vertex.topology = self + + cur_vertex = self.vertices.get(new_vertex.id, None) + if cur_vertex is not None: + # Check consistency + if new_vertex.type != cur_vertex.type: new_vertex.type = None + # Overwriting the old vertex is fine, new_vertex has better info. + self.vertices[new_vertex.id] = new_vertex + + def add_edge(self, edge): + if self.frozen: raise ValueError('Cannot add edge to frozen topology.') + # We need to merge relevant edges. Thus, this is more like adding a + # multi-edge, which falls to no-op when the specific edge is already + # present. In other words, this means that we _may_ have multiple Edge + # objects linking the same vertices, if they only differ by count or + # cost. + # + # Also, since the edges are immutable and only concern generic details, + # the Edge objects are not directly linked to Topology; to make the + # connection, one needs to resolve one of the VertexIDs to the specific + # Vertex to see if the edge is present. + self.vertices[source].outgoing_edges.add(edge) + self.vertices[target].incoming_edges.add(edge) + self.edges.add(edge) + + @classmethod + def combine_topologies(cls, **topologies) -> 'TopologyV3': + result = cls() + for label, topo in topologies: + result.add_ancestor(label, topo) + return result + + # Removing parts of the topology is currently not supported. Not that it + # would be hard, just needless. + +class VertexType(IntEnum): + """Type of entity this vertex represents. + + IntEnum just so we can order by this.""" + + # Routers first: + Router = 100 + ExternalRouter = 101 + + # Transit networks + TransitNet = 200 + + # The various leaves of the topology: external routes, stubnets, … + ExternalNet = 300 + ExtraAreaNet = 301 + StubNet = 302 + +@dataclass(frozen=True) +class VertexID: + """An identifier of a particular vertex. + + This class serves as an immutable identifier of a router/network. The + :class:VertexFinder is used to find the specific :class:Vertex instances + across known topologies. + + We want to keep the following invariant: when two vertices share the same + VertexID, they correspond to the same network object. This requires a bit + of creative interpretation of which network objects are the same and which + are not. + + Conversely, two vertices corresponding to the same object may not share + IDs. While it would be simpler if they did, the :class:VertexFinder is + implemented to aid matching in such cases. Therefore, we rather assign + multiple IDs to the same object than mistakenly share IDs. + + Different network objects are identified by different properties: + - Routers in OSPF always have a Router ID (RID) + - External networks are uniquely determined by its IP range + - Stub networks also need to be linked to the router which sees them + (in case of network split there might be several stubnets with the + same range) + - Transit networks in OSPFv2 always have a single range, but when + split, the RID of the DR can distinguish between the components + - In OSPFv3, transit networks need to be identified by the RID of + the DR and the interface ID leading to the network. Split networks + may have the same ranges and DR in all components and the iface ID is + the only thing BIRD provides us to differentiate. Moreover, in OSPFv3 + a network can have zero or more ranges in it, so identifying by + ranges does not always work. + + While the identifying properties of different objects are different, a + single VertexID object needs to be able to identify the same network across + topologies even in case a network shifts its shape (by splitting, becoming + stub &c.). We therefore do not mark, which kind of network we are dealing + with (i.e. the :class:VertexType), but we rather try to record as much + information as possible. Even then, this might be insufficient for matching + networks across topologies (e.g. when a network changes its DR, the state + before and after may have distinct properties). + + We could try to collect more information (like the list of all the RIDs + present in the network), but that might instead lead us to create a + different VertexID for the same network (e.g. when a router goes down), + which is also undesirable. We therefore do not try to overengineer the + description of the network, leaving such heuristics and advanced matching + to another algorithm.""" + + family: AddressFamily + + # We do _not_ use NodeType, because what is an extra-area network in one + # topology might become a transit network in another. So we only distinguish routers and networks. + is_router: bool + + # We do not want to have multiple competing VertexID implementations, so we + # include all the possible elements here and set them to None when not + # applicable. + + address: IPv4Network | IPv6Network | None + router_id: int | None + # For discriminating networks + dr_id: int | None + discriminator: str | None + + @property + def is_network(self): return not self.is_router + @is_network.setter + def _set_network(self, val: bool): + assert isinstance(val, bool) + self.is_router = not val + +@dataclass +class Vertex: + """Holds the details about a vertex that are specific to this topology. + + The generic information is present in the :class:VertexID. + + This class is not hashable, because while building the topology, the set of + links needs to be mutable and we consider changing the type when freezing + to be premature optimisation and/or needles paranoia. The :class:VertexID + can be used as a hashable handle to the vertex when combined with the + specific :class:TopologyV3 reference.""" + id: VertexID + topology: TopologyV3 | None + type: VertexType | None + outgoing_edges: set['Edge'] = field(default_factory=set) + incoming_edges: set['Edge'] = field(default_factory=set) + +@dataclass(frozen=True) +class Edge: + """An edge of the topology. + + Even though it is an edge, it is not suitable for being directly used in + shortest-path tree calculation, since for tree calculation we need to track + distances, not costs. + + Since the main identifier of an edge is one of the vertices, we do not + implement dedicated registry for edges.""" + source: VertexID + target: VertexID + cost: int + # The network is a multigraph, so we need to know how many times to count this edge + count: int + # Virtual links are basically an implementation detail of OSPF. + # Nonetheless, it might be useful to be able to show them, so we allow + # adding them to a topology. + virtual: bool = False + +@dataclass +class VertexFinder: + """Tracker of presence of vertices in topologies. + + This is a central object that keeps track of multiple instances of the same + vertex (that is, whatever has the same :class:VertexID) and can point to + the relevant :class:Vertex or :class:TopologyV3 objects. + + Apart from just keeping track, this class also provides methods to easy + filtering of the vertices in order to aid with matching corresponding + vertices even when they end up with different VertexIDs.""" + + # In future, this class may also provide keeping track of aliases between + # different VertexIDs. Just noting that this is a good place, but such + # functionality is not required at this time. + + def __init__(self): + self.topologies: list[TopologyV3] = [] + self.vertices: dict[VertexID, list[TopologyV3]] = defaultdict(lambda: []) + + # All of the following dictionaries have scalars as keys and sets of VertexIDs as value. + self.by_addr: dict[IPv4Network | IPv6Network, set[VertexID]] = defaultdict(lambda: set()) + self.by_rid = defaultdict(lambda: set()) + self.by_dr = defaultdict(lambda: set()) + self.by_discriminator = defaultdict(lambda: set()) + self.by_type = defaultdict(lambda: set()) + + def add_topology(self, topo) -> None: + if not topo.frozen: raise ValueError("Can only add frozen topologies.") + if topo in self.topologies: raise KeyError("This topology is already in the finder") + self.topologies.append(topo) + for v in topo.vertices: + id = v.id + self.vertices[id].add(topo) + # Add to various "indices" + self.by_addr[id.address].add(id) + self.by_rid[id.router_id].add(id) + self.by_dr[id.dr_id].add(id) + self.by_discriminator[id.discriminator].add(id) + self.by_type[v.type].add(id) + + def get_vertex(self, vertex_id) -> list[TopologyV3]: + return self.vertices[vertex_id] + + def find(self, **kwa) -> set[VertexID]: + """This allows finding vertices only based by partial information. + + The interface is a bit similar to Django's QuerySet filters, we take + various keyword arguments and return everything that matches all of + them. When an argument is present and is None, we find only those that + have that property unset. + + This function at this time does not check whether argument types are + correct, and fails silently by returning empty set. + + We only do non-trivial matching for addresses (network ranges, we find + any overlaps). + + We cannot do non-None matches (please give me all vertices that have XX + set) at the moment.""" + # Shouldn't be to big. + # TODO: Maybe return a dictionary? + result = set(self.vertices.keys()) + if 'address' in kwa: + addr = kwa['address'] + if addr is None: + result &= self.by_addr[None] + else: + matches = set() + # This might be slow when we do general overlaps. + for net in self.by_addr.keys(): + if net is None: continue + if net.overlaps(addr): + matches |= self.by_addr[net] + result &= matches + if 'rid' in kwa: + result &= self.by_rid[kwa['rid']] + if 'dr' in kwa: + result &= self.by_dr[kwa['dr']] + if 'discriminator' in kwa: + result &= self.by_discriminator[kwa['discriminator']] + if 'type' in kwa: + result &= self.by_type[kwa['type']] + + return result +