diff options
author | Farid Mihoub <farid.mihoub@6wind.com> | 2023-04-25 17:27:46 +0200 |
---|---|---|
committer | Farid Mihoub <farid.mihoub@6wind.com> | 2023-07-12 14:54:06 +0200 |
commit | 875511c466763e7494032736dde747948f29cb2c (patch) | |
tree | 1c0a1670322726b797277bffe1a8c2a703e9a453 /tests | |
parent | Merge pull request #13968 from zhengxiang311019/patch-1 (diff) | |
download | frr-875511c466763e7494032736dde747948f29cb2c.tar.xz frr-875511c466763e7494032736dde747948f29cb2c.zip |
topotests: add basic bmp collector
Signed-off-by: Farid Mihoub <farid.mihoub@6wind.com>
Diffstat (limited to 'tests')
-rw-r--r-- | tests/topotests/lib/bmp_collector/bgp/__init__.py | 0 | ||||
-rw-r--r-- | tests/topotests/lib/bmp_collector/bgp/open/__init__.py | 34 | ||||
-rw-r--r-- | tests/topotests/lib/bmp_collector/bgp/update/__init__.py | 54 | ||||
-rw-r--r-- | tests/topotests/lib/bmp_collector/bgp/update/af.py | 53 | ||||
-rw-r--r-- | tests/topotests/lib/bmp_collector/bgp/update/nlri.py | 140 | ||||
-rw-r--r-- | tests/topotests/lib/bmp_collector/bgp/update/path_attributes.py | 304 | ||||
-rw-r--r-- | tests/topotests/lib/bmp_collector/bgp/update/rd.py | 59 | ||||
-rw-r--r-- | tests/topotests/lib/bmp_collector/bmp.py | 420 | ||||
-rwxr-xr-x | tests/topotests/lib/bmp_collector/bmpserver | 45 | ||||
-rw-r--r-- | tests/topotests/lib/topogen.py | 43 |
10 files changed, 1152 insertions, 0 deletions
diff --git a/tests/topotests/lib/bmp_collector/bgp/__init__.py b/tests/topotests/lib/bmp_collector/bgp/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/tests/topotests/lib/bmp_collector/bgp/__init__.py diff --git a/tests/topotests/lib/bmp_collector/bgp/open/__init__.py b/tests/topotests/lib/bmp_collector/bgp/open/__init__.py new file mode 100644 index 000000000..6c814ee9a --- /dev/null +++ b/tests/topotests/lib/bmp_collector/bgp/open/__init__.py @@ -0,0 +1,34 @@ +# SPDX-License-Identifier: ISC + +# Copyright 2023 6WIND S.A. +# Authored by Farid Mihoub <farid.mihoub@6wind.com> +# +import ipaddress +import struct + + +class BGPOpen: + UNPACK_STR = '!16sHBBHH4sB' + + @classmethod + def dissect(cls, data): + (marker, + length, + open_type, + version, + my_as, + hold_time, + bgp_id, + optional_params_len) = struct.unpack_from(cls.UNPACK_STR, data) + + data = data[struct.calcsize(cls.UNPACK_STR) + optional_params_len:] + + # XXX: parse optional parameters + + return data, { + 'version': version, + 'my_as': my_as, + 'hold_time': hold_time, + 'bgp_id': ipaddress.ip_address(bgp_id), + 'optional_params_len': optional_params_len, + } diff --git a/tests/topotests/lib/bmp_collector/bgp/update/__init__.py b/tests/topotests/lib/bmp_collector/bgp/update/__init__.py new file mode 100644 index 000000000..d079b3511 --- /dev/null +++ b/tests/topotests/lib/bmp_collector/bgp/update/__init__.py @@ -0,0 +1,54 @@ +# SPDX-License-Identifier: ISC + +# Copyright 2023 6WIND S.A. +# Authored by Farid Mihoub <farid.mihoub@6wind.com> +# +import ipaddress +import struct + +from .nlri import NlriIPv4Unicast +from .path_attributes import PathAttribute + + +#------------------------------------------------------------------------------ +class BGPUpdate: + UNPACK_STR = '!16sHBH' + STATIC_SIZE = 23 + + @classmethod + def dissect(cls, data): + msg = {'bmp_log_type': 'update'} + common_size = struct.calcsize(cls.UNPACK_STR) + (marker, + length, + update_type, + withdrawn_routes_len) = struct.unpack_from(cls.UNPACK_STR, data) + + # get withdrawn routes + withdrawn_routes = '' + if withdrawn_routes_len: + withdrawn_routes = NlriIPv4Unicast.parse( + data[common_size:common_size + withdrawn_routes_len] + ) + msg['bmp_log_type'] = 'withdraw' + msg.update(withdrawn_routes) + + # get path attributes + (total_path_attrs_len,) = struct.unpack_from( + '!H', data[common_size+withdrawn_routes_len:]) + + if total_path_attrs_len: + offset = cls.STATIC_SIZE + withdrawn_routes_len + path_attrs_data = data[offset:offset + total_path_attrs_len] + while path_attrs_data: + path_attrs_data, pattr = PathAttribute.dissect(path_attrs_data) + if pattr: + msg = {**msg, **pattr} + + # get nlri + nlri_len = length - cls.STATIC_SIZE - withdrawn_routes_len - total_path_attrs_len + if nlri_len > 0: + nlri = NlriIPv4Unicast.parse(data[length - nlri_len:length]) + msg.update(nlri) + + return data[length:], msg diff --git a/tests/topotests/lib/bmp_collector/bgp/update/af.py b/tests/topotests/lib/bmp_collector/bgp/update/af.py new file mode 100644 index 000000000..01af1ae2b --- /dev/null +++ b/tests/topotests/lib/bmp_collector/bgp/update/af.py @@ -0,0 +1,53 @@ +# SPDX-License-Identifier: ISC + +# Copyright 2023 6WIND S.A. +# Authored by Farid Mihoub <farid.mihoub@6wind.com> +# + +# IANA Address Family Identifier +AFI_IP = 1 +AFI_IP6 = 2 +AFI_L2VPN = 25 + +# IANA Subsequent Address Family Idenitifier +SAFI_UNICAST = 1 +SAFI_MULTICAST = 2 +SAFI_MPLS_LABEL = 4 +SAFI_EVPN = 70 +SAFI_MPLS_VPN = 128 +SAFI_IP_FLOWSPEC = 133 +SAFI_VPN_FLOWSPEC = 134 + + +#------------------------------------------------------------------------------ +class AddressFamily: + def __init__(self, afi, safi): + self.afi = afi + self.safi = safi + + def __eq__(self, other): + if not isinstance(other, type(self)): + return False + return (self.afi, self.safi) == (other.afi, other.safi) + + def __str__(self): + return f'afi: {self.afi}, safi: {self.safi}' + + def __hash__(self): + return hash((self.afi, self.safi)) + + +#------------------------------------------------------------------------------ +class AF: + IPv4_UNICAST = AddressFamily(AFI_IP, SAFI_UNICAST) + IPv6_UNICAST = AddressFamily(AFI_IP6, SAFI_UNICAST) + IPv4_VPN = AddressFamily(AFI_IP, SAFI_MPLS_VPN) + IPv6_VPN = AddressFamily(AFI_IP6, SAFI_MPLS_VPN) + IPv4_MPLS = AddressFamily(AFI_IP, SAFI_MPLS_LABEL) + IPv6_MPLS = AddressFamily(AFI_IP6, SAFI_MPLS_LABEL) + IPv4_FLOWSPEC = AddressFamily(AFI_IP, SAFI_IP_FLOWSPEC) + IPv6_FLOWSPEC = AddressFamily(AFI_IP6, SAFI_IP_FLOWSPEC) + VPNv4_FLOWSPEC = AddressFamily(AFI_IP, SAFI_VPN_FLOWSPEC) + VPNv6_FLOWSPEC = AddressFamily(AFI_IP6, SAFI_VPN_FLOWSPEC) + L2EVPN = AddressFamily(AFI_L2VPN, SAFI_EVPN) + L2VPN_FLOWSPEC = AddressFamily(AFI_L2VPN, SAFI_VPN_FLOWSPEC) diff --git a/tests/topotests/lib/bmp_collector/bgp/update/nlri.py b/tests/topotests/lib/bmp_collector/bgp/update/nlri.py new file mode 100644 index 000000000..c1720f126 --- /dev/null +++ b/tests/topotests/lib/bmp_collector/bgp/update/nlri.py @@ -0,0 +1,140 @@ +# SPDX-License-Identifier: ISC + +# Copyright 2023 6WIND S.A. +# Authored by Farid Mihoub <farid.mihoub@6wind.com> +# +import ipaddress +import struct + +from .af import AddressFamily, AF +from .rd import RouteDistinguisher + + +def decode_label(label): + # from frr + # frr encode just one label + return (label[0] << 12) | (label[1] << 4) | (label[2] & 0xf0) >> 4 + +def padding(databin, len_): + """ + Assumption: + One nlri per update/withdraw message, so we can add + a padding to the prefix without worrying about its length + """ + if len(databin) >= len_: + return databin + return databin + b'\0' * (len_ - len(databin)) + +def dissect_nlri(nlri_data, afi, safi): + """ + Exract nlri information based on the address family + """ + addr_family = AddressFamily(afi, safi) + if addr_family == AF.IPv6_VPN: + return NlriIPv6Vpn.parse(nlri_data) + elif addr_family == AF.IPv4_VPN: + return NlriIPv4Vpn.parse(nlri_data) + elif addr_family == AF.IPv6_UNICAST: + return NlriIPv6Unicast.parse(nlri_data) + + return {'ip_prefix': 'Unknown'} + + +#------------------------------------------------------------------------------ +class NlriIPv4Unicast: + + @staticmethod + def parse(data): + """parses prefixes from withdrawn_routes or nrli data""" + (prefix_len,) = struct.unpack_from('!B', data) + prefix = padding(data[1:], 4) + + return {'ip_prefix': f'{ipaddress.IPv4Address(prefix)}/{prefix_len}'} + + +#------------------------------------------------------------------------------ +class NlriIPv6Unicast: + @staticmethod + def parse(data): + """parses prefixes from withdrawn_routes or nrli data""" + (prefix_len,) = struct.unpack_from('!B', data) + prefix = padding(data[1:], 16) + + return {'ip_prefix': f'{ipaddress.IPv6Address(prefix)}/{prefix_len}'} + + +#------------------------------------------------------------------------------ +class NlriIPv4Vpn: + UNPACK_STR = '!B3s8s' + + @classmethod + def parse(cls, data): + (bit_len, label, rd) = struct.unpack_from(cls.UNPACK_STR, data) + offset = struct.calcsize(cls.UNPACK_STR) + + ipv4 = padding(data[offset:], 4) + # prefix_len = total_bits_len - label_bits_len - rd_bits_len + prefix_len = bit_len - 3*8 - 8*8 + return { + 'label': decode_label(label), + 'rd': str(RouteDistinguisher(rd)), + 'ip_prefix': f'{ipaddress.IPv4Address(ipv4)}/{prefix_len}', + } + + +#------------------------------------------------------------------------------ +class NlriIPv6Vpn: + UNPACK_STR = '!B3s8s' + + @classmethod + def parse(cls, data): + # rfc 3107, 8227 + (bit_len, label, rd) = struct.unpack_from(cls.UNPACK_STR, data) + offset = struct.calcsize(cls.UNPACK_STR) + + ipv6 = padding(data[offset:], 16) + prefix_len = bit_len - 3*8 - 8*8 + return { + 'label': decode_label(label), + 'rd': str(RouteDistinguisher(rd)), + 'ip_prefix': f'{ipaddress.IPv6Address(ipv6)}/{prefix_len}', + } + + +#------------------------------------------------------------------------------ +class NlriIPv4Mpls: + pass + + +#------------------------------------------------------------------------------ +class NlriIPv6Mpls: + pass + + +#------------------------------------------------------------------------------ +class NlriIPv4FlowSpec: + pass + + +#------------------------------------------------------------------------------ +class NlriIPv6FlowSpec: + pass + + +#------------------------------------------------------------------------------ +class NlriVpn4FlowSpec: + pass + + +#------------------------------------------------------------------------------ +class NlriVpn6FlowSpec: + pass + + +#------------------------------------------------------------------------------ +class NlriL2EVPN: + pass + +#------------------------------------------------------------------------------ +class NlriL2VPNFlowSpec: + pass diff --git a/tests/topotests/lib/bmp_collector/bgp/update/path_attributes.py b/tests/topotests/lib/bmp_collector/bgp/update/path_attributes.py new file mode 100644 index 000000000..6e82e9c17 --- /dev/null +++ b/tests/topotests/lib/bmp_collector/bgp/update/path_attributes.py @@ -0,0 +1,304 @@ +# SPDX-License-Identifier: ISC + +# Copyright 2023 6WIND S.A. +# Authored by Farid Mihoub <farid.mihoub@6wind.com> +# +import struct +import ipaddress + +from . import nlri as NLRI +from .af import AddressFamily, AF +from .rd import RouteDistinguisher + + +PATH_ATTR_FLAG_OPTIONAL = 1 << 7 +PATH_ATTR_FLAG_TRANSITIVE = 1 << 6 +PATH_ATTR_FLAG_PARTIAL = 1 << 5 +PATH_ATTR_FLAG_EXTENDED_LENGTH = 1 << 4 + +PATH_ATTR_TYPE_ORIGIN = 1 +PATH_ATTR_TYPE_AS_PATH = 2 +PATH_ATTR_TYPE_NEXT_HOP = 3 +PATH_ATTR_TYPE_MULTI_EXIT_DISC = 4 +PATH_ATTR_TYPE_LOCAL_PREF = 5 +PATH_ATTR_TYPE_ATOMIC_AGGREGATE = 6 +PATH_ATTR_TYPE_AGGREGATOR = 7 +PATH_ATTR_TYPE_COMMUNITIES = 8 +PATH_ATTR_TYPE_ORIGINATOR_ID = 9 +PATH_ATTR_TYPE_CLUSTER_LIST = 10 +PATH_ATTR_TYPE_MP_REACH_NLRI = 14 +PATH_ATTR_TYPE_MP_UNREACH_NLRI = 15 +PATH_ATTR_TYPE_EXTENDED_COMMUNITIES = 16 +PATH_ATTR_TYPE_AS4_PATH = 17 +PATH_ATTR_TYPE_AS4_AGGREGATOR = 18 +PATH_ATTR_TYEP_PMSI_TUNNEL_ATTRIBUTE = 22 + +ORIGIN_IGP = 0x00 +ORIGIN_EGP = 0x01 +ORIGIN_INCOMPLETE = 0x02 + + +#------------------------------------------------------------------------------ +class PathAttribute: + PATH_ATTRS = {} + UNKNOWN_ATTR = None + UNPACK_STR = '!BB' + + @classmethod + def register_path_attr(cls, path_attr): + def _register_path_attr(subcls): + cls.PATH_ATTRS[path_attr] = subcls + return subcls + return _register_path_attr + + @classmethod + def lookup_path_attr(cls, type_code): + return cls.PATH_ATTRS.get(type_code, cls.UNKNOWN_ATTR) + + @classmethod + def dissect(cls, data): + flags, type_code = struct.unpack_from(cls.UNPACK_STR, data) + offset = struct.calcsize(cls.UNPACK_STR) + + # get attribute length + attr_len_str = '!H' if (flags & PATH_ATTR_FLAG_EXTENDED_LENGTH) else '!B' + + (attr_len,) = struct.unpack_from(attr_len_str, data[offset:]) + + offset += struct.calcsize(attr_len_str) + + path_attr_cls = cls.lookup_path_attr(type_code) + if path_attr_cls == cls.UNKNOWN_ATTR: + return data[offset + attr_len:], None + + return data[offset+attr_len:], path_attr_cls.dissect(data[offset:offset+attr_len]) + + +#------------------------------------------------------------------------------ +@PathAttribute.register_path_attr(PATH_ATTR_TYPE_ORIGIN) +class PathAttrOrigin: + ORIGIN_STR = { + ORIGIN_IGP: 'IGP', + ORIGIN_EGP: 'EGP', + ORIGIN_INCOMPLETE: 'INCOMPLETE', + } + + @classmethod + def dissect(cls, data): + (origin,) = struct.unpack_from('!B', data) + + return {'origin': cls.ORIGIN_STR.get(origin, 'UNKNOWN')} + + +#------------------------------------------------------------------------------ +@PathAttribute.register_path_attr(PATH_ATTR_TYPE_AS_PATH) +class PathAttrAsPath: + AS_PATH_TYPE_SET = 0x01 + AS_PATH_TYPE_SEQUENCE= 0x02 + + @staticmethod + def get_asn_len(asns): + """XXX: Add this nightmare to determine the ASN length""" + pass + + @classmethod + def dissect(cls, data): + (_type, _len) = struct.unpack_from('!BB', data) + data = data[2:] + + _type_str = 'Ordred' if _type == cls.AS_PATH_TYPE_SEQUENCE else 'Raw' + segment = [] + while data: + (asn,) = struct.unpack_from('!I', data) + segment.append(asn) + data = data[4:] + + return {'as_path': ' '.join(str(a) for a in segment)} + + +#------------------------------------------------------------------------------ +@PathAttribute.register_path_attr(PATH_ATTR_TYPE_NEXT_HOP) +class PathAttrNextHop: + @classmethod + def dissect(cls, data): + (nexthop,) = struct.unpack_from('!4s', data) + return {'bgp_nexthop': str(ipaddress.IPv4Address(nexthop))} + + +#------------------------------------------------------------------------------ +class PathAttrMultiExitDisc: + pass + + +#------------------------------------------------------------------------------ +@PathAttribute.register_path_attr(PATH_ATTR_TYPE_MP_REACH_NLRI) +class PathAttrMpReachNLRI: + """ + +---------------------------------------------------------+ + | Address Family Identifier (2 octets) | + +---------------------------------------------------------+ + | Subsequent Address Family Identifier (1 octet) | + +---------------------------------------------------------+ + | Length of Next Hop Network Address (1 octet) | + +---------------------------------------------------------+ + | Network Address of Next Hop (variable) | + +---------------------------------------------------------+ + | Number of SNPAs (1 octet) | + +---------------------------------------------------------+ + | Length of first SNPA(1 octet) | + +---------------------------------------------------------+ + | First SNPA (variable) | + +---------------------------------------------------------+ + | Length of second SNPA (1 octet) | + +---------------------------------------------------------+ + | Second SNPA (variable) | + +---------------------------------------------------------+ + | ... | + +---------------------------------------------------------+ + | Length of Last SNPA (1 octet) | + +---------------------------------------------------------+ + | Last SNPA (variable) | + +---------------------------------------------------------+ + | Network Layer Reachability Information (variable) | + +---------------------------------------------------------+ + """ + UNPACK_STR = '!HBB' + NLRI_RESERVED_LEN = 1 + + @staticmethod + def dissect_nexthop(nexthop_data, nexthop_len): + msg = {} + if nexthop_len == 4: + # IPv4 + (ipv4,) = struct.unpack_from('!4s', nexthop_data) + msg['nxhp_ip'] = str(ipaddress.IPv4Address(ipv4)) + elif nexthop_len == 12: + # RD + IPv4 + (rd, ipv4) = struct.unpack_from('!8s4s', nexthop_data) + msg['nxhp_ip'] = str(ipaddress.IPv4Address(ipv4)) + msg['nxhp_rd'] = str(RouteDistinguisher(rd)) + elif nexthop_len == 16: + # IPv6 + (ipv6,) = struct.unpack_from('!16s', nexthop_data) + msg['nxhp_ip'] = str(ipaddress.IPv6Address(ipv6)) + elif nexthop_len == 24: + # RD + IPv6 + (rd, ipv6) = struct.unpack_from('!8s16s', nexthop_data) + msg['nxhp_ip'] = str(ipaddress.IPv6Address(ipv6)) + msg['nxhp_rd'] = str(RouteDistinguisher(rd)) + elif nexthop_len == 32: + # IPv6 + IPv6 link-local + (ipv6, link_local)= struct.unpack_from('!16s16s', nexthop_data) + msg['nxhp_ip'] = str(ipaddress.IPv6Address(ipv6)) + msg['nxhp_link-local'] = str(ipaddress.IPv6Address(link_local)) + elif nexthop_len == 48: + # RD + IPv6 + RD + IPv6 link-local + u_str = '!8s16s8s16s' + (rd1, ipv6, rd2, link_local)= struct.unpack_from(u_str, nexthop_data) + msg['nxhp_rd1'] = str(RouteDistinguisher(rd1)) + msg['nxhp_ip'] = str(ipaddress.IPv6Address(ipv6)) + msg['nxhp_rd2'] = str(RouteDistinguisher(rd2)) + msg['nxhp_link-local'] = str(ipaddress.IPv6Address(link_local)) + + return msg + + @staticmethod + def dissect_snpa(snpa_data): + pass + + @classmethod + def dissect(cls, data): + (afi, safi, nexthop_len) = struct.unpack_from(cls.UNPACK_STR, data) + offset = struct.calcsize(cls.UNPACK_STR) + msg = {'afi': afi, 'safi': safi} + + # dissect nexthop + nexthop_data = data[offset: offset + nexthop_len] + nexthop = cls.dissect_nexthop(nexthop_data, nexthop_len) + msg.update(nexthop) + + offset += nexthop_len + # dissect snpa or just reserved + offset += 1 + # dissect nlri + nlri = NLRI.dissect_nlri(data[offset:], afi, safi) + msg.update(nlri) + + return msg + + +#------------------------------------------------------------------------------ +@PathAttribute.register_path_attr(PATH_ATTR_TYPE_MP_UNREACH_NLRI) +class PathAttrMpUnReachNLRI: + """ + +---------------------------------------------------------+ + | Address Family Identifier (2 bytes) | + +---------------------------------------------------------+ + | Subsequent Address Family Identifier (1 byte) | + +---------------------------------------------------------+ + | Withdrawn Routes (variable) | + +---------------------------------------------------------+ + """ + UNPACK_STR = '!HB' + + @classmethod + def dissect(cls, data): + (afi, safi) = struct.unpack_from(cls.UNPACK_STR, data) + offset = struct.calcsize(cls.UNPACK_STR) + msg = {'bmp_log_type': 'withdraw','afi': afi, 'safi': safi} + + if data[offset:]: + # dissect withdrawn_routes + msg.update(NLRI.dissect_nlri(data[offset:], afi, safi)) + + return msg + + +#------------------------------------------------------------------------------ +class PathAttrLocalPref: + pass + + +#------------------------------------------------------------------------------ +class PathAttrAtomicAgregate: + pass + + +#------------------------------------------------------------------------------ +class PathAttrAggregator: + pass + + +#------------------------------------------------------------------------------ +class PathAttrCommunities: + pass + + +#------------------------------------------------------------------------------ +class PathAttrOriginatorID: + pass + + +#------------------------------------------------------------------------------ +class PathAttrClusterList: + pass + + +#------------------------------------------------------------------------------ +class PathAttrExtendedCommunities: + pass + + +#------------------------------------------------------------------------------ +class PathAttrPMSITunnel: + pass + + +#------------------------------------------------------------------------------ +class PathAttrLinkState: + pass + + +#------------------------------------------------------------------------------ +class PathAttrLargeCommunities: + pass diff --git a/tests/topotests/lib/bmp_collector/bgp/update/rd.py b/tests/topotests/lib/bmp_collector/bgp/update/rd.py new file mode 100644 index 000000000..c382fa834 --- /dev/null +++ b/tests/topotests/lib/bmp_collector/bgp/update/rd.py @@ -0,0 +1,59 @@ +# SPDX-License-Identifier: ISC + +# Copyright 2023 6WIND S.A. +# Authored by Farid Mihoub <farid.mihoub@6wind.com> +# +import ipaddress +import struct + + +#------------------------------------------------------------------------------ +class RouteDistinguisher: + """ + type 0: + +---------------------------------------------------------------------+ + + type=0 (2 bytes)| Administrator subfield | Assigned number subfiled | + + | AS number (2 bytes) | Service Provider 4 bytes)| + +---------------------------------------------------------------------+ + + type 1: + +---------------------------------------------------------------------+ + + type=1 (2 bytes)| Administrator subfield | Assigned number subfiled | + + | IPv4 (4 bytes) | Service Provider 2 bytes)| + +---------------------------------------------------------------------+ + + type 2: + +-------------------------------------------------------------------------+ + + type=2 (2 bytes)| Administrator subfield | Assigned number subfiled | + + | 4-bytes AS number (4 bytes)| Service Provider 2 bytes)| + +-------------------------------------------------------------------------+ + """ + def __init__(self, rd): + self.rd = rd + self.as_number = None + self.admin_ipv4 = None + self.four_bytes_as = None + self.assigned_sp = None + self.repr_str = '' + self.dissect() + + def dissect(self): + (rd_type,) = struct.unpack_from('!H', self.rd) + if rd_type == 0: + (self.as_number, + self.assigned_sp) = struct.unpack_from('!HI', self.rd[2:]) + self.repr_str = f'{self.as_number}:{self.assigned_sp}' + + elif rd_type == 1: + (self.admin_ipv4, + self.assigned_sp) = struct.unpack_from('!IH', self.rd[2:]) + ipv4 = str(ipaddress.IPv4Address(self.admin_ipv4)) + self.repr_str = f'{self.as_number}:{self.assigned_sp}' + + elif rd_type == 2: + (self.four_bytes_as, + self.assigned_sp) = struct.unpack_from('!IH', self.rd[2:]) + self.repr_str = f'{self.four_bytes_as}:{self.assigned_sp}' + + def __str__(self): + return self.repr_str diff --git a/tests/topotests/lib/bmp_collector/bmp.py b/tests/topotests/lib/bmp_collector/bmp.py new file mode 100644 index 000000000..b07329cd5 --- /dev/null +++ b/tests/topotests/lib/bmp_collector/bmp.py @@ -0,0 +1,420 @@ +# SPDX-License-Identifier: ISC + +# Copyright 2023 6WIND S.A. +# Authored by Farid Mihoub <farid.mihoub@6wind.com> +# +""" +BMP main module: + - dissect monitoring messages in the way to get updated/withdrawed prefixes + - XXX: missing RFCs references + - XXX: more bmp messages types to dissect + - XXX: complete bgp message dissection +""" +import datetime +import ipaddress +import json +import os +import struct + +from bgp.update import BGPUpdate +from bgp.update.rd import RouteDistinguisher + + +SEQ = 0 +LOG_DIR = "/var/log/" +LOG_FILE = "/var/log/bmp.log" + +IS_ADJ_RIB_OUT = 1 << 4 +IS_AS_PATH = 1 << 5 +IS_POST_POLICY = 1 << 6 +IS_IPV6 = 1 << 7 +IS_FILTERED = 1 << 7 + +if not os.path.exists(LOG_DIR): + os.makedirs(LOG_DIR) + +def bin2str_ipaddress(ip_bytes, is_ipv6=False): + if is_ipv6: + return str(ipaddress.IPv6Address(ip_bytes)) + return str(ipaddress.IPv4Address(ip_bytes[-4:])) + +def log2file(logs): + """ + XXX: extract the useful information and save it in a flat dictionnary + """ + with open(LOG_FILE, 'a') as f: + f.write(json.dumps(logs) + "\n") + + +#------------------------------------------------------------------------------ +class BMPCodes: + """ + XXX: complete the list, provide RFCs. + """ + VERSION = 0x3 + + BMP_MSG_TYPE_ROUTE_MONITORING = 0x00 + BMP_MSG_TYPE_STATISTICS_REPORT = 0x01 + BMP_MSG_TYPE_PEER_DOWN_NOTIFICATION = 0x02 + BMP_MSG_TYPE_PEER_UP_NOTIFICATION = 0x03 + BMP_MSG_TYPE_INITIATION = 0x04 + BMP_MSG_TYPE_TERMINATION = 0x05 + BMP_MSG_TYPE_ROUTE_MIRRORING = 0x06 + BMP_MSG_TYPE_ROUTE_POLICY = 0x64 + + # initiation message types + BMP_INIT_INFO_STRING = 0x00 + BMP_INIT_SYSTEM_DESCRIPTION = 0x01 + BMP_INIT_SYSTEM_NAME = 0x02 + BMP_INIT_VRF_TABLE_NAME = 0x03 + BMP_INIT_ADMIN_LABEL = 0x04 + + # peer types + BMP_PEER_GLOBAL_INSTANCE = 0x00 + BMP_PEER_RD_INSTANCE = 0x01 + BMP_PEER_LOCAL_INSTANCE = 0x02 + BMP_PEER_LOC_RIB_INSTANCE = 0x03 + + # peer header flags + BMP_PEER_FLAG_IPV6 = 0x80 + BMP_PEER_FLAG_POST_POLICY = 0x40 + BMP_PEER_FLAG_AS_PATH = 0x20 + BMP_PEER_FLAG_ADJ_RIB_OUT = 0x10 + + # peer loc-rib flag + BMP_PEER_FLAG_LOC_RIB = 0x80 + BMP_PEER_FLAG_LOC_RIB_RES = 0x7F + + # statistics type + BMP_STAT_PREFIX_REJ = 0x00 + BMP_STAT_PREFIX_DUP = 0x01 + BMP_STAT_WITHDRAW_DUP = 0x02 + BMP_STAT_CLUSTER_LOOP = 0x03 + BMP_STAT_AS_LOOP = 0x04 + BMP_STAT_INV_ORIGINATOR = 0x05 + BMP_STAT_AS_CONFED_LOOP = 0x06 + BMP_STAT_ROUTES_ADJ_RIB_IN = 0x07 + BMP_STAT_ROUTES_LOC_RIB = 0x08 + BMP_STAT_ROUTES_PER_ADJ_RIB_IN = 0x09 + BMP_STAT_ROUTES_PER_LOC_RIB = 0x0A + BMP_STAT_UPDATE_TREAT = 0x0B + BMP_STAT_PREFIXES_TREAT = 0x0C + BMP_STAT_DUPLICATE_UPDATE = 0x0D + BMP_STAT_ROUTES_PRE_ADJ_RIB_OUT = 0x0E + BMP_STAT_ROUTES_POST_ADJ_RIB_OUT = 0x0F + BMP_STAT_ROUTES_PRE_PER_ADJ_RIB_OUT = 0x10 + BMP_STAT_ROUTES_POST_PER_ADJ_RIB_OUT = 0x11 + + # peer down reason code + BMP_PEER_DOWN_LOCAL_NOTIFY = 0x01 + BMP_PEER_DOWN_LOCAL_NO_NOTIFY = 0X02 + BMP_PEER_DOWN_REMOTE_NOTIFY = 0X03 + BMP_PEER_DOWN_REMOTE_NO_NOTIFY = 0X04 + BMP_PEER_DOWN_INFO_NO_LONGER = 0x05 + BMP_PEER_DOWN_SYSTEM_CLOSED = 0X06 + + # termincation message types + BMP_TERM_TYPE_STRING = 0x00 + BMP_TERM_TYPE_REASON = 0X01 + + # termination reason code + BMP_TERM_REASON_ADMIN_CLOSE = 0x00 + BMP_TERM_REASON_UNSPECIFIED = 0x01 + BMP_TERM_REASON_RESOURCES = 0x02 + BMP_TERM_REASON_REDUNDANT = 0x03 + BMP_TERM_REASON_PERM_CLOSE = 0x04 + + # policy route tlv + BMP_ROUTE_POLICY_TLV_VRF = 0x00 + BMP_ROUTE_POLICY_TLV_POLICY= 0x01 + BMP_ROUTE_POLICY_TLV_PRE_POLICY = 0x02 + BMP_ROUTE_POLICY_TLV_POST_POLICY = 0x03 + BMP_ROUTE_POLICY_TLV_STRING = 0x04 + + +#------------------------------------------------------------------------------ +class BMPMsg: + """ + XXX: should we move register_msg_type and look_msg_type + to generic Type class. + """ + TYPES = {} + UNKNOWN_TYPE = None + HDR_STR = '!BIB' + MIN_LEN = struct.calcsize(HDR_STR) + TYPES_STR = { + BMPCodes.BMP_MSG_TYPE_INITIATION: 'initiation', + BMPCodes.BMP_MSG_TYPE_PEER_DOWN_NOTIFICATION: 'peer down notification', + BMPCodes.BMP_MSG_TYPE_PEER_UP_NOTIFICATION: 'peer up notification', + BMPCodes.BMP_MSG_TYPE_ROUTE_MONITORING: 'route monitoring', + BMPCodes.BMP_MSG_TYPE_STATISTICS_REPORT: 'statistics report', + BMPCodes.BMP_MSG_TYPE_TERMINATION: 'termination', + BMPCodes.BMP_MSG_TYPE_ROUTE_MIRRORING: 'route mirroring', + BMPCodes.BMP_MSG_TYPE_ROUTE_POLICY: 'route policy', + } + + @classmethod + def register_msg_type(cls, msgtype): + def _register_type(subcls): + cls.TYPES[msgtype] = subcls + return subcls + return _register_type + + @classmethod + def lookup_msg_type(cls, msgtype): + return cls.TYPES.get(msgtype, cls.UNKNOWN_TYPE) + + @classmethod + def dissect_header(cls, data): + """ + 0 1 2 3 4 5 6 7 8 1 2 3 4 5 6 7 8 1 2 3 4 5 6 7 8 1 2 3 4 5 6 7 8 + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | Version | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | Message Length | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | Message Type | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + """ + if len(data) < cls.MIN_LEN: + pass + else: + _version, _len, _type = struct.unpack(cls.HDR_STR, data[0:cls.MIN_LEN]) + return _version, _len, _type + + @classmethod + def dissect(cls, data): + global SEQ + version, msglen, msgtype = cls.dissect_header(data) + + msg_data = data[cls.MIN_LEN:msglen] + data = data[msglen:] + + if version != BMPCodes.VERSION: + # XXX: log something + return data + + msg_cls = cls.lookup_msg_type(msgtype) + if msg_cls == cls.UNKNOWN_TYPE: + # XXX: log something + return data + + msg_cls.MSG_LEN = msglen - cls.MIN_LEN + logs = msg_cls.dissect(msg_data) + logs["seq"] = SEQ + log2file(logs) + SEQ += 1 + + return data + + +#------------------------------------------------------------------------------ +class BMPPerPeerMessage: + """ + 0 1 2 3 4 5 6 7 8 1 2 3 4 5 6 7 8 1 2 3 4 5 6 7 8 1 2 3 4 5 6 7 8 + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | Peer Type | Peer Flags | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | Peer Address (16 bytes) | + ~ ~ + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | Peer AS | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | Peer BGP ID | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | Timestamp (seconds) | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | Timestamp (microseconds) | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + """ + PEER_UNPACK_STR = '!BB8s16sI4sII' + PEER_TYPE_STR = { + BMPCodes.BMP_PEER_GLOBAL_INSTANCE: 'global instance', + BMPCodes.BMP_PEER_RD_INSTANCE: 'route distinguisher instance', + BMPCodes.BMP_PEER_LOCAL_INSTANCE: 'local instance', + BMPCodes.BMP_PEER_LOC_RIB_INSTANCE: 'loc-rib instance', + } + + @classmethod + def dissect(cls, data): + (peer_type, + peer_flags, + peer_distinguisher, + peer_address, + peer_asn, + peer_bgp_id, + timestamp_secs, + timestamp_microsecs) = struct.unpack_from(cls.PEER_UNPACK_STR, data) + + msg = {'peer_type': cls.PEER_TYPE_STR[peer_type]} + + if peer_type == 0x03: + msg['is_filtered'] = bool(peer_flags & IS_FILTERED) + else: + # peer_flags = 0x0000 0000 + # ipv6, post-policy, as-path, adj-rib-out, reserverdx4 + is_adj_rib_out = bool(peer_flags & IS_ADJ_RIB_OUT) + is_as_path = bool(peer_flags & IS_AS_PATH) + is_post_policy = bool(peer_flags & IS_POST_POLICY) + is_ipv6 = bool(peer_flags & IS_IPV6) + msg['post_policy'] = is_post_policy + msg['ipv6'] = is_ipv6 + msg['peer_ip'] = bin2str_ipaddress(peer_address, is_ipv6) + + + peer_bgp_id = bin2str_ipaddress(peer_bgp_id) + timestamp = float(timestamp_secs) + timestamp_microsecs * (10 ** -6) + + data = data[struct.calcsize(cls.PEER_UNPACK_STR):] + msg.update({ + 'peer_distinguisher': str(RouteDistinguisher(peer_distinguisher)), + 'peer_asn': peer_asn, + 'peer_bgp_id': peer_bgp_id, + 'timestamp': str(datetime.datetime.fromtimestamp(timestamp)), + }) + + return data, msg + + +#------------------------------------------------------------------------------ +@BMPMsg.register_msg_type(BMPCodes.BMP_MSG_TYPE_ROUTE_MONITORING) +class BMPRouteMonitoring(BMPPerPeerMessage): + + @classmethod + def dissect(cls, data): + data, peer_msg = super().dissect(data) + data, update_msg = BGPUpdate.dissect(data) + return {**peer_msg, **update_msg} + + +#------------------------------------------------------------------------------ +class BMPStatisticsReport: + """ + 0 1 2 3 4 5 6 7 8 1 2 3 4 5 6 7 8 1 2 3 4 5 6 7 8 1 2 3 4 5 6 7 8 + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | Stats Count | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | Stat Type | Stat Len | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | Stat Data | + ~ ~ + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + """ + pass + + +#------------------------------------------------------------------------------ +class BMPPeerDownNotification: + """ + 0 1 2 3 4 5 6 7 8 1 2 3 4 5 6 7 8 1 2 3 4 5 6 7 8 1 2 3 4 5 6 7 8 + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | Reason | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | Data (present if Reason = 1, 2 or 3) | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + """ + pass + + +#------------------------------------------------------------------------------ +@BMPMsg.register_msg_type(BMPCodes.BMP_MSG_TYPE_PEER_UP_NOTIFICATION) +class BMPPeerUpNotification(BMPPerPeerMessage): + """ + 0 1 2 3 4 5 6 7 8 1 2 3 4 5 6 7 8 1 2 3 4 5 6 7 8 1 2 3 4 5 6 7 8 + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | Local Address (16 bytes) | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | Local Port | Remote Port | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | Sent OPEN Message #| + ~ ~ + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | Received OPEN Message | + ~ ~ + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + """ + UNPACK_STR = '!16sHH' + MIN_LEN = struct.calcsize(UNPACK_STR) + MSG_LEN = None + + @classmethod + def dissect(cls, data): + data, peer_msg = super().dissect(data) + + (local_addr, + local_port, + remote_port) = struct.unpack_from(cls.UNPACK_STR, data) + + msg = { + **peer_msg, + **{ + 'local_ip': bin2str_ipaddress(local_addr, peer_msg.get('ipv6')), + 'local_port': int(local_port), + 'remote_port': int(remote_port), + }, + } + + # XXX: dissect the bgp open message + + return msg + + +#------------------------------------------------------------------------------ +@BMPMsg.register_msg_type(BMPCodes.BMP_MSG_TYPE_INITIATION) +class BMPInitiation: + """ + 0 1 2 3 4 5 6 7 8 1 2 3 4 5 6 7 8 1 2 3 4 5 6 7 8 1 2 3 4 5 6 7 8 + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | Information Type | Information Length | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | Information (variable) | + ~ ~ + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + """ + TLV_STR = '!HH' + MIN_LEN = struct.calcsize(TLV_STR) + FIELD_TO_STR = { + BMPCodes.BMP_INIT_INFO_STRING: 'information', + BMPCodes.BMP_INIT_ADMIN_LABEL: 'admin_label', + BMPCodes.BMP_INIT_SYSTEM_DESCRIPTION: 'system_description', + BMPCodes.BMP_INIT_SYSTEM_NAME: 'system_name', + BMPCodes.BMP_INIT_VRF_TABLE_NAME: 'vrf_table_name', + } + + @classmethod + def dissect(cls, data): + msg = {} + while len(data) > cls.MIN_LEN: + _type, _len = struct.unpack_from(cls.TLV_STR, data[0:cls.MIN_LEN]) + _value = data[cls.MIN_LEN: cls.MIN_LEN + _len].decode() + + msg[cls.FIELD_TO_STR[_type]] = _value + data = data[cls.MIN_LEN + _len:] + + return msg + + +#------------------------------------------------------------------------------ +class BMPTermination: + """ + 0 1 2 3 4 5 6 7 8 1 2 3 4 5 6 7 8 1 2 3 4 5 6 7 8 1 2 3 4 5 6 7 8 + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | Information Type | Information Length | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | Information (variable) | + ~ ~ + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + """ + pass + + +#------------------------------------------------------------------------------ +class BMPRouteMirroring: + pass + + +#------------------------------------------------------------------------------ +class BMPRoutePolicy: + pass diff --git a/tests/topotests/lib/bmp_collector/bmpserver b/tests/topotests/lib/bmp_collector/bmpserver new file mode 100755 index 000000000..25b4a52c5 --- /dev/null +++ b/tests/topotests/lib/bmp_collector/bmpserver @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: ISC + +# Copyright 2023 6WIND S.A. +# Authored by Farid Mihoub <farid.mihoub@6wind.com> +# +import argparse +# XXX: something more reliable should be used "Twisted" a great choice. +import socket +import sys + +from bmp import BMPMsg + +BGP_MAX_SIZE = 4096 + +parser = argparse.ArgumentParser() +parser.add_argument("-a", "--address", type=str, default="0.0.0.0") +parser.add_argument("-p", "--port", type=int, default=1789) + +def main(): + args = parser.parse_args() + ADDRESS, PORT = args.address, args.port + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.bind((ADDRESS, PORT)) + s.listen() + connection, _ = s.accept() + + try: + while True: + data = connection.recv(BGP_MAX_SIZE) + while len(data) > BMPMsg.MIN_LEN: + data = BMPMsg.dissect(data) + except Exception as e: + # XXX: do something + pass + except KeyboardInterrupt: + # XXX: do something + pass + finally: + connection.close() + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/topotests/lib/topogen.py b/tests/topotests/lib/topogen.py index 6ddd223e2..4d935b953 100644 --- a/tests/topotests/lib/topogen.py +++ b/tests/topotests/lib/topogen.py @@ -363,6 +363,15 @@ class Topogen(object): self.peern += 1 return self.gears[name] + def add_bmp_server(self, name, ip, defaultRoute, port=1789): + """Add the bmp collector gear""" + if name in self.gears: + raise KeyError("The bmp server already exists") + + self.gears[name] = TopoBMPCollector( + self, name, ip=ip, defaultRoute=defaultRoute, port=port + ) + def add_link(self, node1, node2, ifname1=None, ifname2=None): """ Creates a connection between node1 and node2. The nodes can be the @@ -425,6 +434,13 @@ class Topogen(object): """ return self.get_gears(TopoExaBGP) + def get_bmp_servers(self): + """ + Retruns the bmp servers dictionnary (the key is the bmp server the + value is the bmp server object itself). + """ + return self.get_gears(TopoBMPCollector) + def start_topology(self): """Starts the topology class.""" logger.info("starting topology: {}".format(self.modname)) @@ -1204,6 +1220,33 @@ class TopoExaBGP(TopoHost): return "" +class TopoBMPCollector(TopoHost): + PRIVATE_DIRS = [ + "/var/log", + ] + + def __init__(self, tgen, name, **params): + params["private_mounts"] = self.PRIVATE_DIRS + self.port = params["port"] + self.ip = params["ip"] + super(TopoBMPCollector, self).__init__(tgen, name, **params) + + def __str__(self): + gear = super(TopoBMPCollector, self).__str__() + gear += " TopoBMPCollector<>".format() + return gear + + def start(self): + self.run( + "{}/bmp_collector/bmpserver -a {} -p {}&".format(CWD, self.ip, self.port), + stdout=None, + ) + + def stop(self): + self.run("pkill -9 -f bmpserver") + return "" + + # # Diagnostic function # |