You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
birdvisu/birdvisu/providers.py

530 lines
22 KiB
Python

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 .ospfsock import BirdSocketConnection, BirdError
from subprocess import Popen, PIPE
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 len(self.future_transit_networks_edges) > 0:
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('-')
dr_id = self.parse_fake_ip(dr_id)
candidates = list(filter(lambda vid: vid.dr_id == dr_id and vid.discriminator == discr, result.vertices))
if len(candidates) != 1: raise OspfFileParseError(f'Multiple or no 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)
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.items():
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 r'-' in ident 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 = ident.lstrip(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
class OspfDataTopologyProvider(TopologyProvider):
"""Returns a topology from a static data.
Very basic, but may be used as a tool when the topology is received from a
source for which there is no provider."""
def __init__(self, data: str, version=None, area=None, freeze=True):
# No need to wait, parse the topology right away
parser = OspfFileTopologyParser(version=version, area=area)
self.topology = parser.parse(data, freeze=freeze)
def get_topology(self):
return self.topology
class OspfFileTopologyProvider(TopologyProvider):
"""Returns a topology from a given file.
It can cope with the file being changed, we only read the topology in
get_topology."""
def __init__(self, filename, version=None, area=None):
self.filename = filename
self.area = area
self.version = version
def get_topology(self):
with open(self.filename, 'r') as f:
data = f.read()
parser = OspfFileTopologyParser(version=self.version, area=self.area)
# No more data in the file, so we can freeze now.
return parser.parse(data, freeze=True)
class ProcessTopologyProvider(TopologyProvider):
"""Retrieves a topology from a process which outputs an ospffile with the
topology.
The process is invoked in each call of get_topology and is expected to end
sucessfully.
This is not suitable to retrieve data from BIRD, use
:class:BirdSocketTopologyProvider instead. For example, this can not cope
with multiple instances of OSPF running in BIRD."""
def __init__(self, command: str | list[str], version=None, area=None, freeze=True):
self.command = command.split() if isinstance(command, str) else command
self.version = version
self.area = area
self.freeze = freeze
def get_topology(self):
process = Popen(self.command, stdout=PIPE, stderr=PIPE, stdin=PIPE, text=True)
stdout, stderr = process.communicate()
if process.returncode != 0 or stderr != '':
raise RuntimeError(f'The command failed. Return code: {process.returncode}, stderr: {process.stderr}')
# stdout contains the data.
parser = OspfFileTopologyParser(version=self.version, area=self.area)
return parser.parse(stdout, freeze=self.freeze)
class BirdMultipleInstancesError(BirdError):
"""BIRD has multiple known OSPF's running.
This exception is intended to be created from existing BirdError. (We stil
re-raise, so the error message is sane.)"""
def __init__(self, orig_exc: BirdError, protocols: list[str]):
for attr in ['code', 'start', 'end', 'text']:
val = getattr(orig_exc, attr)
setattr(self, attr, val)
self.protocols = protocols
class BirdSocketTopologyProvider(TopologyProvider):
"""Connects to a running BIRD and retrieves current topology.
This allows to select instance of OSPF. If there are more instances, we
raise a :class:BirdMultipleInstancesError with list of the possible
instances.
We do not keep the socket open, so if BIRD restarts or anything similar
happens, we survive.
Unfortunately, BIRD does not expose the version of the instance, so it is
either guessed or you must provide it."""
def __init__(self, socket='/run/bird/bird.ctl', all=False, instance=None,
area=None, version=None, freeze=True):
self.socket_filename = socket
self.all = all
self.instance = instance
self.area = area
self.version = version
self.freeze = freeze
def get_topology(self):
sock = BirdSocketConnection(self.socket_filename)
request = 'show ospf state'
if self.all:
request += ' all'
if self.instance is not None:
request += ' ' + self.instance
try:
response = sock.request(request)
except BirdError as e:
if e.text == 'There are multiple OSPF protocols running\n':
protocols = self.find_running_ospf(sock)
raise BirdMultipleInstancesError(e, protocols) from e
raise
parser = OspfFileTopologyParser(version=self.version, area=self.area)
return parser.parse(response.text, freeze=self.freeze)
def find_running_ospf(self, bird):
resp = bird.request('show protocols')
# There are three sections: the header, the table itself and a 0000 at
# the end.
assert len(resp.codes) == 3
assert resp.codes[0] == ((1,1), '2002')
assert resp.codes[1][1] == '1002'
lines = resp.text.splitlines()
header = lines[0]
fields = header.split()
assert fields[0] == 'Name'
assert fields[1] == 'Proto'
assert fields[3] == 'State'
# The output looks familiar, so we parse it.
start, end = resp.codes[1][0]
# The numbers are line numbers, so we need to subtract one from start
# to get index. End points after the end, that is correct.
table = lines[start-1:end]
result = []
for line in table:
name, proto, _table, state, *_rest = line.split()
if proto == 'OSPF' and state == 'up':
result.append(name)
return result