diff --git a/README.md b/README.md index 9209cf4aca73434dbe4fed878d996a1102c59651..9e11d586ccebd030d84cf89bb6c494ab8edf28f6 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[PACKAGE]: ../../raw/master/packagee-0.1.2-20230706.mkp "package-0.1.2-20230706.mkp" +[PACKAGE]: ../../raw/master/mkp/bgp_topology-0.0.1-20240722.mkp "bgp_topology-0.0.1-20240722.mkp" # Title A short description about the plugin diff --git a/mkp/bgp_topology-0.0.1-20240722.mkp b/mkp/bgp_topology-0.0.1-20240722.mkp new file mode 100644 index 0000000000000000000000000000000000000000..c2016d68bdc1e69682bf793b7e24f92f311828cb Binary files /dev/null and b/mkp/bgp_topology-0.0.1-20240722.mkp differ diff --git a/source/checkman/.gitkeep b/source/checkman/.gitkeep deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/source/checkman/bgp_topology b/source/checkman/bgp_topology deleted file mode 100644 index 08ef898bcf6eb7a7979de60090d54f9719bbbc5b..0000000000000000000000000000000000000000 --- a/source/checkman/bgp_topology +++ /dev/null @@ -1,45 +0,0 @@ -title: Dummy check man page - used as template for new check manuals -agents: linux, windows, aix, solaris, hpux, vms, freebsd, snmp -catalog: see modules/catalog.py for possible values -license: GPL -distribution: check_mk -description: - Describe here: (1) what the check actually does, (2) under which - circumstances it goes warning/critical, (3) which devices are supported - by the check, (4) if the check requires a separated plugin or - tool or separate configuration on the target host. - -item: - Describe the syntax and meaning of the check's item here. Provide all - information one needs if coding a manual check with {checks +=} in {main.mk}. - Give an example. If the check uses {None} as sole item, - then leave out this section. - -examples: - # Give examples for configuration in {main.mk} here. If the check has - # configuration variable, then give example for them here. - - # set default levels to 40 and 60 percent: - foo_default_values = (40, 60) - - # another configuration variable here: - inventory_foo_filter = [ "superfoo", "superfoo2" ] - -perfdata: - Describe precisely the number and meaning of performance variables - the check sends. If it outputs no performance data, then leave out this - section. - -inventory: - Describe how the inventory for the check works. Which items - will it find? Describe the influence of check specific - configuration parameters to the inventory. - -[parameters] -foofirst(int): describe the first parameter here (if parameters are grouped - as tuple) -fooother(string): describe another parameter here. - -[configuration] -foo_default_levels(int, int): Describe global configuration variable of - foo here. Important: also tell the user how they are preset. diff --git a/source/cmk_addons_plugins/bgp_topology/constants.py b/source/cmk_addons_plugins/bgp_topology/constants.py new file mode 100644 index 0000000000000000000000000000000000000000..12130842fea6c98ee690dd806891f7ce8ca55f34 --- /dev/null +++ b/source/cmk_addons_plugins/bgp_topology/constants.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# License: GNU General Public License v2 +# +# Author: thl-cmk[at]outlook[dot]com +# URL : https://thl-cmk.hopto.org +# Date : 2024-07-16 +# File : bgp_topology/constants.py + +# 2024-07-16: copied from bgp_topology/lib/ruleset_names.py + + +from typing import Final + +ARG_PARSER_ANCHOR: Final[str] = '--bgp-anchor' +ARG_PARSER_ANCHOR_AS: Final[str] = 'bgp-as' +ARG_PARSER_ANCHOR_BOTH: Final[str] = 'both' +ARG_PARSER_ANCHOR_ID: Final[str] = 'bgp-id' +ARG_PARSER_EMBLEM_AS: Final[str] = '--bgp-emblem-as' +ARG_PARSER_EMBLEM_ID: Final[str] = '--bgp-emblem-id' +ARG_PARSER_HOST: Final[str] = '--host' +ARG_PARSER_MAKE_DEFAULT: Final[str] = '--make-default' +ARG_PARSER_NONE: Final[str] = 'none' +ARG_PARSER_SITES_EXCLUDE: Final[str] = '--exclude-sites' +ARG_PARSER_SITES_INCLUDE: Final[str] = '--include-sites' + +BGP_PEER_LOCAL_ADDR: Final[str] = 'local_addr' +BGP_PEER_LOCAL_AS: Final[str] = 'local_as' +BGP_PEER_LOCAL_ID: Final[str] = 'local_id' +BGP_PEER_REMOTE_ADDR: Final[str] = 'remote_addr' +BGP_PEER_REMOTE_AS: Final[str] = 'remote_as' +BGP_PEER_REMOTE_ID: Final[str] = 'remote_id' +BGP_PEER_STATE: Final[str] = 'state' +BGP_PEER_UPTIME: Final[str] = 'uptime' + +EMBLEM_BGP_ID: Final[str] = 'icon_topic_system' # icon_plugins_hw +EMBLEM_BGP_AS: Final[str] = 'icon_cloud' + +METRIC_TIME_TAKEN: Final[str] = 'topology_time_taken' + +PARAM_BGP_AS: Final[str] = 'bgp_as' +PARAM_BGP_EXT_ANCHOR: Final[str] = 'bgp_ext_anchor' +PARAM_BGP_EXT_ANCHOR_AS: Final[str] = 'bgp_ext_anchor_as' +PARAM_BGP_EXT_ANCHOR_BOTH: Final[str] = 'bgp_ext_anchor_both' +PARAM_BGP_EXT_ANCHOR_ID: Final[str] = 'bgp_ext_anchor_id' +PARAM_BGP_EXT_ANCHOR_NONE: Final[str] = 'bgp_ext_anchor_none' +PARAM_BGP_ID: Final[str] = 'bgp_id' +PARAM_BGP_SITES_EXCLUDE: Final[str] = 'exclude_sites' +PARAM_BGP_SITES_FILTER: Final[str] = 'bgp_filter_sites' +PARAM_BGP_SITES_INCLUDE: Final[str] = 'include_sites' +PARAM_EMBLEM_CUSTOM: Final[str] = 'custom_emblem' +PARAM_EMBLEM_DEFAULT: Final[str] = 'default_emblem' +PARAM_EMBLEM_NO_EMBLEM: Final[str] = 'no_emblem' +PARAM_MAKE_DEFAULT: Final[str] = 'make_default' + +PICTURE_TYPE_EMBLEM: Final[str] = 'emblem' +PICTURE_TYPE_ICON: Final[str] = 'icon' + +RULE_SET_NAME_BGP_TOPOLOGY: Final[str] = 'bgp_topology' + +TOPOLOGY_NAME: Final[str] = 'BGP' diff --git a/source/cmk_addons_plugins/bgp_topology/graphing/bgp_topology.py b/source/cmk_addons_plugins/bgp_topology/graphing/bgp_topology.py new file mode 100644 index 0000000000000000000000000000000000000000..69af60e589348c9bfbce6def4dd8cae8e637feac --- /dev/null +++ b/source/cmk_addons_plugins/bgp_topology/graphing/bgp_topology.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# License: GNU General Public License v2 +# +# Author: thl-cmk[at]outlook[dot]com +# URL : https://thl-cmk.hopto.org +# Date : 2024-07-16 +# File : bgp_topology/graphing/bgp_topology.py + +from cmk.graphing.v1 import Title +from cmk.graphing.v1.graphs import Graph +from cmk.graphing.v1.metrics import Color, Metric, Unit, TimeNotation, AutoPrecision +from cmk.graphing.v1.perfometers import Closed, FocusRange, Open, Perfometer + +from cmk_addons.plugins.bgp_topology.constants import METRIC_TIME_TAKEN + +metric_topology_time_taken = Metric( + name=METRIC_TIME_TAKEN, + title=Title('Time taken'), + unit=Unit(TimeNotation(), AutoPrecision(4)), + color=Color.BLUE, +) + +graph_topology_time_taken = Graph( + name=METRIC_TIME_TAKEN, + title=Title('Time taken'), + compound_lines=[METRIC_TIME_TAKEN], +) + +perfometer_topology_time_taken = Perfometer( + name=METRIC_TIME_TAKEN, + focus_range=FocusRange(Closed(0), Open(1)), + segments=[METRIC_TIME_TAKEN] +) diff --git a/source/cmk_addons_plugins/bgp_topology/lib/bgp_topology.py b/source/cmk_addons_plugins/bgp_topology/lib/bgp_topology.py new file mode 100644 index 0000000000000000000000000000000000000000..76e4d0a1e2d536927c255b938173823de93f3bfd --- /dev/null +++ b/source/cmk_addons_plugins/bgp_topology/lib/bgp_topology.py @@ -0,0 +1,344 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# License: GNU General Public License v2 +# +# Author: thl-cmk[at]outlook[dot]com +# URL : https://thl-cmk.hopto.org +# Date : 2024-07-16 +# File : bgp_topology/lib/bgp_topology.py + +# 2024-07-20: moved to lib -> is now an active check + +__AUTHOR__ = 'thl-cmk[at]outlook[dot]com' +__URL__ = 'https://thl-cmk.hopto.org/gitlab/checkmk/vendor-independent/bgp_topology' +__USAGE__ = '/local/lib/python3/cmk_addons/plugins/bgp_topology/libexec/check_bgp_topology --make-default' +__VERSION__ = '0.0.1-20240721' + +from argparse import ArgumentParser, Namespace, RawTextHelpFormatter +from collections.abc import MutableMapping, MutableSequence, Sequence +from dataclasses import dataclass +from time import time_ns + +from cmk.agent_based.v2 import render +from cmk_addons.plugins.bgp_topology.constants import ( + ARG_PARSER_ANCHOR, + ARG_PARSER_ANCHOR_AS, + ARG_PARSER_ANCHOR_BOTH, + ARG_PARSER_ANCHOR_ID, + ARG_PARSER_EMBLEM_AS, + ARG_PARSER_EMBLEM_ID, + ARG_PARSER_HOST, + ARG_PARSER_MAKE_DEFAULT, + ARG_PARSER_NONE, + ARG_PARSER_SITES_EXCLUDE, + ARG_PARSER_SITES_INCLUDE, + BGP_PEER_LOCAL_ADDR, + BGP_PEER_LOCAL_AS, + BGP_PEER_LOCAL_ID, + BGP_PEER_REMOTE_ADDR, + BGP_PEER_REMOTE_AS, + BGP_PEER_REMOTE_ID, + BGP_PEER_STATE, + BGP_PEER_UPTIME, + EMBLEM_BGP_AS, + EMBLEM_BGP_ID, + METRIC_TIME_TAKEN, + PARAM_BGP_EXT_ANCHOR_AS, + PARAM_BGP_EXT_ANCHOR_BOTH, + PARAM_BGP_EXT_ANCHOR_ID, + TOPOLOGY_NAME, +) +from cmk_addons.plugins.bgp_topology.lib.utils import ( + BASE_TOPO_PATH, + LiveStatusConnection, + Metric, + OMD_ROOT, + TopoConnections, + TopoObjects, + add_dummy_topologies, + get_anchor, + get_bgp_peer_clean_key, + get_bgp_peer_clean_value, + get_emblem, + get_service, + make_topo_default, + save_topology, +) + + +class Params(Namespace): + bgp_anchor: str + make_default: bool = False + bgp_emblem_as: str = EMBLEM_BGP_AS + bgp_emblem_id: str = EMBLEM_BGP_ID + include_sites: Sequence[str] | None = None + exclude_sites: Sequence[str] | None = None + host: str = '' + + +@dataclass(frozen=True) +class BgpPeer: + host: str + service: str + state: int + state_str: str + remote_address: str + uptime: str | None = None + local_as: str | None = None + local_address: str | None = None + local_id: str | None = None + remote_as: str | None = None + remote_id: str | None = None + + @classmethod + def parse(cls, host: str, state: int, servie: str, raw_peer_data: str): + raw_peer_attributes: Sequence[str] = raw_peer_data.split('\\n') + + bgp_peer: MutableMapping[str, str] = {} + for entry in raw_peer_attributes: + try: + key, value = entry.split(':', 1) + except ValueError: + key = None + value = None + key: str | None = get_bgp_peer_clean_key(key) + value: str | None = get_bgp_peer_clean_value(key, value) + if key is not None and value is not None: + bgp_peer[key] = value + + return cls( + host=host, + local_address=bgp_peer.get(BGP_PEER_LOCAL_ADDR), + local_as=bgp_peer.get(BGP_PEER_LOCAL_AS), + local_id=bgp_peer.get(BGP_PEER_LOCAL_ID), + remote_address=bgp_peer.get(BGP_PEER_REMOTE_ADDR), + remote_as=bgp_peer.get(BGP_PEER_REMOTE_AS), + remote_id=bgp_peer.get(BGP_PEER_REMOTE_ID), + service=servie, + state=state, + state_str=bgp_peer.get(BGP_PEER_STATE), + uptime=bgp_peer.get(BGP_PEER_UPTIME), + ) + + +def create_bgp_topology(params: Params | None) -> int: + start_time = time_ns() + + summary: list[str] = [] + details: list[str] = [] + + objects = TopoObjects() + connections = TopoConnections() + sub_directory = TOPOLOGY_NAME + + ls_connection = LiveStatusConnection() + if params.include_sites is not None: + ls_connection.filter_sites(include=True, sites=params.include_sites) + sites_str: str = ', '.join(params.include_sites) + details.append(f'Site(s) included: {sites_str}') + sub_directory = f'{TOPOLOGY_NAME}_{params.host}' + elif params.exclude_sites is not None: + ls_connection.filter_sites(include=False, sites=params.exclude_sites) + sites_str: str = ', '.join(params.exclude_sites) + details.append(f'Site(s) excluded: {sites_str}') + sub_directory = f'{TOPOLOGY_NAME}_{params.host}' + + bgp_anchor = get_anchor(params.bgp_anchor) + emblem_as = get_emblem(params.bgp_emblem_as) + emblem_id = get_emblem(params.bgp_emblem_id) + + query: str = ( + 'GET services\n' + 'Columns: host_name state description long_plugin_output\n' + 'Filter: description ~ BGP peer\n' + 'OutputFormat: python3\n' + ) + if (raw_bgp_peers := ls_connection.query(query=query)) is not None: + bgp_peers: MutableSequence[BgpPeer] = [ + BgpPeer.parse(host, state, service, raw_peer_data) for host, state, service, raw_peer_data in raw_bgp_peers + ] + + # create index by local address, add host and service objects + bgp_peers_by_local_addr: MutableMapping[str, MutableSequence[BgpPeer]] = {'0.0.0.0': []} + for bgp_peer in bgp_peers: + objects.add_host(bgp_peer.host) + objects.add_service(bgp_peer.host, bgp_peer.service) + connections.add_connection( + right=bgp_peer.host, + left=get_service(bgp_peer.host, bgp_peer.service), + left_state=bgp_peer.state + ) + + if bgp_peer.local_address is not None: + if bgp_peers_by_local_addr.get(bgp_peer.local_address) is None: + bgp_peers_by_local_addr[bgp_peer.local_address]: MutableSequence[BgpPeer] = [] + bgp_peers_by_local_addr[bgp_peer.local_address].append(bgp_peer) + else: + bgp_peers_by_local_addr['0.0.0.0'].append(bgp_peer) + + # find connections + for bgp_peer in bgp_peers: + if (peer_list := bgp_peers_by_local_addr.get(bgp_peer.remote_address)) is not None: + for peer in peer_list: + if bgp_peer.local_as == peer.remote_as and bgp_peer.local_as is not None: + if bgp_peer.local_id == peer.remote_id and bgp_peer.local_id is not None: + if bgp_peer.local_address == peer.remote_address and bgp_peer.local_address is not None: + connections.add_connection( + left=get_service(bgp_peer.host, bgp_peer.service), + right=get_service(peer.host, peer.service), + right_state=bgp_peer.state, + left_state=peer.state, + ) + + # if there is no peer_list the peer is either external (to checkmk) or the connection is not + # established, so we have no local address or remote id + else: + ext_bgp_host_as: str | None = None + ext_bgp_host_id: str | None = None + if bgp_anchor in [ + PARAM_BGP_EXT_ANCHOR_BOTH, + PARAM_BGP_EXT_ANCHOR_AS, + ] and bgp_peer.remote_as is not None: + ext_bgp_host_as: str = f'BGP-AS: {bgp_peer.remote_as}' + objects.add_host( + host=ext_bgp_host_as, + link2core=False, + emblem=emblem_as, + ) + if bgp_anchor in [ + PARAM_BGP_EXT_ANCHOR_BOTH, + PARAM_BGP_EXT_ANCHOR_ID + ] and bgp_peer.remote_id is not None: + ext_bgp_host_id: str = f'BGP-ID: {bgp_peer.remote_id}' + objects.add_host( + host=ext_bgp_host_id, + link2core=False, + emblem=emblem_id + ) + connections.add_connection( + right=ext_bgp_host_id, + left=get_service(bgp_peer.host, bgp_peer.service), + left_state=bgp_peer.state + ) + if ext_bgp_host_as is not None: + connections.add_connection(ext_bgp_host_id, ext_bgp_host_as) + + if ext_bgp_host_as is not None and ext_bgp_host_id is None: + connections.add_connection( + right=get_service(bgp_peer.host, bgp_peer.service), + left=ext_bgp_host_as, + right_state=bgp_peer.state, + ) + + connections.topo_connections.sort() + data_set = { + 'version': 1, + 'name': TOPOLOGY_NAME, + 'objects': dict(sorted(objects.topo_objects.items())), + 'connections': connections.topo_connections, + } + + save_topology(data=data_set, sub_directory=sub_directory) + # workaround for backend is only picking up topologies from default folder + add_dummy_topologies(sub_directory=sub_directory) + # end workaround + make_topo_default(sub_directory=sub_directory, make_default=params.make_default) + + summary.append(f'Objects: {len(objects.topo_objects)}') + details.append(f'Objects: {len(objects.topo_objects)}') + + summary.append(f'Connections: {len(connections.topo_connections)}') + details.append(f'Connections: {len(connections.topo_connections)}') + + details.append(f'Written to: {BASE_TOPO_PATH}/{sub_directory}/data_{TOPOLOGY_NAME.lower()}.json') + + value = (time_ns() - start_time) / 1e9 + summary.append(f'Time taken: {render.timespan(value)}') + details.append(f'Time taken: {render.timespan(value)}') + + perf_data = Metric( + name=METRIC_TIME_TAKEN, + value=value, + # levels=None, + # boundaries=None, + ) + + all_summary: str = ', '.join(summary) + all_details: str = '\n'.join(details) + print(f'{all_summary}\n{all_details}|{perf_data}') + return 0 + + +def parse_arguments(argv: Sequence[str]) -> Params: + parser = ArgumentParser( + prog='bgp_topology', + formatter_class=RawTextHelpFormatter, + description=f"""Create BGP peer network topology for Checkmk""", + epilog=f""" +Example usage: +{OMD_ROOT}/{__USAGE__} + +Version: {__VERSION__} | Written by {__AUTHOR__} +for more information see: {__URL__} + """ + ) + parser.add_argument( + ARG_PARSER_ANCHOR, + choices=[ + ARG_PARSER_ANCHOR_BOTH, + ARG_PARSER_ANCHOR_AS, + ARG_PARSER_ANCHOR_ID, + ARG_PARSER_NONE, + ], + default=ARG_PARSER_ANCHOR_BOTH, + help='Anchor for external BGP objects (default: %(default)s).', + ) + parser.add_argument( + ARG_PARSER_EMBLEM_AS, + type=str, + default=EMBLEM_BGP_AS, + help='Emblem to use for BGP-AS objects (default: %(default)s).', + ) + parser.add_argument( + ARG_PARSER_EMBLEM_ID, + type=str, + default=EMBLEM_BGP_ID, + help='Emblem to use for BGP-ID objects (default: %(default)s).', + ) + parser.add_argument( + ARG_PARSER_MAKE_DEFAULT, + action='store_const', const=True, + default=False, + help='Make this topology the default (default: %(default)s).', + ) + parser.add_argument( + ARG_PARSER_HOST, + type=str, + help="""The name of the Checkmk host to which the plugin is attached. This is set +automatically by Checkmk. If a site filter is active, the host name is +appended to the subdirectory where the topology is stored (“BGP†becomes +“BGP_host_nameâ€). This way we can have more than one BGP topology without +overwriting each other.""", + ) + site_filter = parser.add_mutually_exclusive_group() + site_filter.add_argument( + ARG_PARSER_SITES_INCLUDE, + type=str, + nargs='+', + help=f"""List of Checkmk site names to include in the topology creation. +Can not used together with {ARG_PARSER_SITES_EXCLUDE}""", + ) + site_filter.add_argument( + ARG_PARSER_SITES_EXCLUDE, + type=str, + nargs='+', + help=f"""List of Checkmk site names to exclude from the topology creation. +Can not used together with {ARG_PARSER_SITES_INCLUDE}""", + ) + + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + return create_bgp_topology(parse_arguments(argv=argv)) diff --git a/source/cmk_addons_plugins/bgp_topology/lib/utils.py b/source/cmk_addons_plugins/bgp_topology/lib/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..9c930671e62c8b8653998b7418024993120fb0f3 --- /dev/null +++ b/source/cmk_addons_plugins/bgp_topology/lib/utils.py @@ -0,0 +1,336 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# License: GNU General Public License v2 +# +# Author: thl-cmk[at]outlook[dot]com +# URL : https://thl-cmk.hopto.org +# Date : 2024-07-16 +# File : bgp_topology/lib/utils.py + +# 2024-07-16: copied from vsphere_topology/lib/utils.py + +from collections.abc import Mapping, MutableMapping, MutableSequence, Sequence +from dataclasses import dataclass +from json import dumps as json_dunps, loads as json_loads +from os import environ +from pathlib import Path +from typing import Final, Tuple + +from livestatus import MultiSiteConnection, SiteConfigurations, SiteId +from cmk_addons.plugins.bgp_topology.constants import ( + ARG_PARSER_NONE, + BGP_PEER_LOCAL_ADDR, + BGP_PEER_LOCAL_AS, + BGP_PEER_LOCAL_ID, + BGP_PEER_REMOTE_ADDR, + BGP_PEER_REMOTE_AS, + BGP_PEER_REMOTE_ID, + BGP_PEER_STATE, + BGP_PEER_UPTIME, + PARAM_BGP_EXT_ANCHOR_AS, + PARAM_BGP_EXT_ANCHOR_BOTH, + PARAM_BGP_EXT_ANCHOR_ID, + TOPOLOGY_NAME, +) + +OMD_ROOT = environ['OMD_ROOT'] +BASE_TOPO_PATH: Final[str] = f'{OMD_ROOT}/var/check_mk/topology/data' + + +def get_bgp_peer_clean_key(raw_key: str) -> str | None: + key_map: Mapping = { + 'Local AS': BGP_PEER_LOCAL_AS, + 'Local address': BGP_PEER_LOCAL_ADDR, + 'Local identifier': BGP_PEER_LOCAL_ID, + 'Peer state': BGP_PEER_STATE, + 'Remote AS': BGP_PEER_REMOTE_AS, + 'Remote address': BGP_PEER_REMOTE_ADDR, + 'Remote identifier': BGP_PEER_REMOTE_ID, + 'Uptime': BGP_PEER_UPTIME, + } + + return key_map.get(raw_key) + + +def get_bgp_peer_clean_value(key: str, value: str) -> str | None: + class MatchValues: + local_addr = BGP_PEER_LOCAL_ADDR + remote_addr = BGP_PEER_REMOTE_ADDR + remote_id = BGP_PEER_REMOTE_ID + + if value is not None: + value = value.strip() + match key: + case MatchValues.local_addr | MatchValues.remote_addr | MatchValues.remote_id: + if value.strip() in ['0.0.0.0', 'N/A']: + return + if value: + return value + + +def get_service(host: str, servie: str) -> str: + return f'{servie}@{host}' + + +def get_anchor(raw_anchor: str) -> str | None: + anchor_map = { + 'both': PARAM_BGP_EXT_ANCHOR_BOTH, + 'as': PARAM_BGP_EXT_ANCHOR_AS, + 'id': PARAM_BGP_EXT_ANCHOR_ID, + 'none': ARG_PARSER_NONE + } + return anchor_map.get(raw_anchor) + + +def get_emblem(emblem: str) -> str | None: + if emblem != ARG_PARSER_NONE: + return emblem + + +def save_topology(data: Mapping, sub_directory: str) -> None: + """ + Save the topology as json file under $OMD_ROOT/var/check_mk/topology/data/{sub_directory}.data_{data['name']}.json + the filename will be changed to lower case. + Args: + data: the topology data + sub_directory: the subdirectory were to save the data under ~/var/check_mk/topology/data/ + + Returns: + None + """ + file_name = f'data_{data["name"]}.json'.lower() + save_file = Path(f'{BASE_TOPO_PATH}/{sub_directory}/{file_name}') + save_file.parent.mkdir(exist_ok=True, parents=True) + save_file.write_text(json_dunps(data)) + + +def make_topo_default(sub_directory: str, make_default: bool) -> None: + """ + Create the symlink "default" to $OMD_ROOT/var/check_mk/topology/data/{sub_directory} in + $OMD_ROOT/var/check_mk/topology/data/ if it don't exist or mage_default is True + Args: + sub_directory: the subdirectory under ~/var/check_mk/topology/data/ thaht become default + make_default: if True, create the symlink "default" with path as target + + Returns: + None + """ + + target_path = f'{BASE_TOPO_PATH}/{sub_directory}' + + if not Path(f'{BASE_TOPO_PATH}/default').exists(): + make_default = True + if make_default: + Path(f'{BASE_TOPO_PATH}/default').unlink(missing_ok=True) + Path(f'{BASE_TOPO_PATH}/default').symlink_to(target=Path(target_path), target_is_directory=True) + + +def get_topologies() -> Sequence[str | None]: + """ + Returns a list of topology names form the default typology directory. + + Returns: + List of str ie: ['CDP', 'LLDP'] + """ + path: str = f'{BASE_TOPO_PATH}/default' + if not Path(path).exists(): + return [] + + files = [f for f in Path(path).glob('*.json') if f.is_file()] + return [ + json_loads(Path(file).read_text())['name'] for file in files if + json_loads(Path(file).read_text()).get('name') is not None + ] + + +def add_dummy_topologies(sub_directory: str): + path: str = f'{BASE_TOPO_PATH}/default' + + # don't overwrite existing topology + if Path(path).exists() and not Path(f'{path}/data_{TOPOLOGY_NAME.lower()}.json').exists(): + dummy_topology = {'version': 1, 'name': TOPOLOGY_NAME, 'objects': {}, 'connections': []} + save_topology( + data=dummy_topology, + sub_directory='default', + ) + + for topology in get_topologies(): + if not Path(f'{BASE_TOPO_PATH}/{sub_directory}/data_{topology.lower()}.json').exists(): + save_topology( + data={'version': 1, 'name': topology, 'objects': {}, 'connections': []}, + sub_directory=sub_directory + ) + + +# +# live status +# +class LiveStatusConnection: + def __init__(self): + self.sites: SiteConfigurations = SiteConfigurations({}) + self.sites_mk = Path(f'{OMD_ROOT}/etc/check_mk/multisite.d/sites.mk') + self.socket_path = f'unix:{OMD_ROOT}/tmp/run/live' + if self.sites_mk.exists(): + # make eval() "secure" + # https://realpython.com/python-eval-function/#minimizing-the-security-issues-of-eval + _code = compile(self.sites_mk.read_text(), '<string>', 'eval') + allowed_names = ['sites', 'update'] + for name in _code.co_names: + if name not in allowed_names: + raise NameError(f'Use of {name} in {self.sites_mk.name} not allowed.') + + sites_raw: MutableMapping = {} + eval(self.sites_mk.read_text(), {'__builtins__': {}}, {'sites': sites_raw}) + + for site, data in sites_raw.items(): + self.sites.update({site: { + 'alias': data['alias'], + 'timeout': data['timeout'], + }}) + if data['socket'] == ('local', None): + self.sites[site]['socket'] = self.socket_path + else: + protocol, socket = data['socket'] + address, port = socket['address'] + self.sites[site]['socket'] = f'{protocol}:{address}:{port}' + self.sites[site]['tls'] = socket['tls'] + else: + self.sites.update({SiteId('local'): { + 'alias': 'Local site', + 'timeout': 5, + 'socket': self.socket_path + }}) + + self.c = MultiSiteConnection(self.sites) + dead_sites = [site['site']['alias'] for site in self.c.dead_sites().values()] + if dead_sites: + self.c.set_only_sites(self.c.alive_sites()) + + def query(self, query: str): + data: MutableSequence[Tuple[str, str]] = self.c.query(query=query) + + if data: + return data + else: + return None + + def filter_sites(self, include: bool, sites: Sequence[str]): + if include is True: + site_list = [site for site in self.c.sites if site in sites] + else: + site_list = [site for site in self.c.sites if site not in sites] + + self.c.set_only_sites(site_list) + + +class TopoObjects: + def __init__(self) -> None: + self.topo_objects: MutableMapping[str, object] = {} + + def add_host( + self, + host: str, + emblem: str | None = None, + icon: str | None = None, + link2core: bool = True, + obj_id_prefix: str = '', + ): + if self.topo_objects.get(f'{obj_id_prefix}{host}') is not None: + return + + metadata = {} + if emblem or icon: + metadata = {'images': {}} + + if emblem is not None: + metadata['images'].update({'emblem': emblem}) # node image + + if icon is not None: + metadata['images'].update({'icon': icon}) # node icon + + self.topo_objects[f'{obj_id_prefix}{host}'] = { + 'name': host, + 'link': {'core': host} if link2core else {}, + 'metadata': metadata, + } + + def add_service( + self, + host: str, + service: str, + emblem: str | None = None, + link2core: bool = True, + ): + obj_id = f'{service}@{host}' + if self.topo_objects.get(obj_id) is not None: + return + + metadata = {} + if emblem is not None: + metadata = { + 'images': { + 'emblem': emblem, # node image + }, + } + + self.topo_objects[obj_id] = { + 'name': service, + 'link': {'core': [host, service]} if link2core else {}, + 'metadata': metadata, + } + + +def get_connection_color(left_state: int, right_state:int) -> str: + state_to_color = { + 0: 'white', + 1: 'yellow', + 2: 'red', + 3: 'orange', + } + + return state_to_color.get(max(left_state, right_state), 'orange') + + +class TopoConnections: + def __init__(self) -> None: + self.topo_connections: MutableSequence = [] + self.clean_connections: MutableSequence[Sequence[str]] = [] + + def add_connection( + self, + left: str, + right: str, + left_state: int = 0, + right_state: int = 0, + ): + connection = [left, right] + connection.sort() + if connection not in self.clean_connections: + self.clean_connections.append(connection) + self.topo_connections.append([ + connection, { + 'line_config': { + 'css_styles': { + 'stroke-dasharray': 'unset' + }, + 'color': get_connection_color(left_state, right_state), + } + } + ]) + + +# taken from https://github.com/Checkmk/checkmk/blob/master/cmk/plugins/smb/lib/check_disk_smb.py +# Change-Id: I7426f6553a906c5ac50a8306157931a10640a526 +@dataclass +class Metric: + name: str + value: float + levels: tuple[float, float] | None = None + boundaries: tuple[float, float] | None = None + + def __str__(self) -> str: + l = f"{self.levels[0]};{self.levels[1]};" if self.levels else ";;" + b = f"{self.boundaries[0]};{self.boundaries[1]}" if self.boundaries else ";" + # I'm not too sure about the single quotes here, but keeping it for now + return f"'{self.name}'={self.value}B;{l}{b}" \ No newline at end of file diff --git a/source/cmk_addons_plugins/bgp_topology/libexec/check_bgp_topology b/source/cmk_addons_plugins/bgp_topology/libexec/check_bgp_topology new file mode 100755 index 0000000000000000000000000000000000000000..d1c8d4301ade1ade080ac1b3d0f575c9bb628e91 --- /dev/null +++ b/source/cmk_addons_plugins/bgp_topology/libexec/check_bgp_topology @@ -0,0 +1,18 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# License: GNU General Public License v2 +# +# Author: thl-cmk[at]outlook[dot]com +# URL : https://thl-cmk.hopto.org +# Date : 2024-07-20 +# File : bgp_topology/lib_exec/bgp_topology + +import sys + +from cmk_addons.plugins.bgp_topology.lib.bgp_topology import main + +if __name__ == "__main__": + sys.exit(main()) + + diff --git a/source/cmk_addons_plugins/bgp_topology/rulesets/bgp_topology.py b/source/cmk_addons_plugins/bgp_topology/rulesets/bgp_topology.py new file mode 100644 index 0000000000000000000000000000000000000000..7038083d2970f647b89687ae72037af9a1a4ecf1 --- /dev/null +++ b/source/cmk_addons_plugins/bgp_topology/rulesets/bgp_topology.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# License: GNU General Public License v2 +# +# Author: thl-cmk[at]outlook[dot]com +# URL : https://thl-cmk.hopto.org +# Date : 2024-07-06 +# File : bgp_topology/rulesets/bgp_topology.py + +from collections.abc import Sequence + +from cmk.rulesets.v1 import Help, Label, Message, Title +from cmk.rulesets.v1.form_specs import ( + CascadingSingleChoice, + CascadingSingleChoiceElement, + DefaultValue, + DictElement, + Dictionary, + FixedValue, + SingleChoice, + SingleChoiceElement, + String, + List +) + +from cmk.rulesets.v1.form_specs.validators import LengthInRange, ValidationError +from cmk.rulesets.v1.rule_specs import ActiveCheck, Topic +from cmk_addons.plugins.bgp_topology.constants import ( + EMBLEM_BGP_AS, + EMBLEM_BGP_ID, + PARAM_BGP_AS, + PARAM_BGP_EXT_ANCHOR, + PARAM_BGP_EXT_ANCHOR_AS, + PARAM_BGP_EXT_ANCHOR_BOTH, + PARAM_BGP_EXT_ANCHOR_ID, + PARAM_BGP_EXT_ANCHOR_NONE, + PARAM_BGP_ID, + PARAM_BGP_SITES_EXCLUDE, + PARAM_BGP_SITES_FILTER, + PARAM_BGP_SITES_INCLUDE, + PARAM_EMBLEM_CUSTOM, + PARAM_EMBLEM_DEFAULT, + PARAM_EMBLEM_NO_EMBLEM, + PARAM_MAKE_DEFAULT, + PICTURE_TYPE_EMBLEM, + RULE_SET_NAME_BGP_TOPOLOGY, +) + + +class DuplicateInList: # pylint: disable=too-few-public-methods + """ Custom validator that ensures the validated list has no duplicate entries. """ + + def __init__( + self, + ) -> None: + pass + + @staticmethod + def _get_default_errmsg(_duplicates: Sequence) -> Message: + return Message(f"Duplicate element in list. Duplicate elements: {', '.join(_duplicates)}") + + def __call__(self, value: List[str] | None) -> None: + if not isinstance(value, list): + return + _duplicates = [value[i] for i, x in enumerate(value) if value.count(x) > 1] + _duplicates = list(set(_duplicates)) + if _duplicates: + raise ValidationError(message=self._get_default_errmsg(_duplicates)) + + +def get_emblem_element(default_emblem: str, picture_type: str) -> Sequence[CascadingSingleChoiceElement]: + return [ + CascadingSingleChoiceElement( + name=PARAM_EMBLEM_NO_EMBLEM, + title=Title(f'No custom {picture_type}'), + parameter_form=FixedValue( + value=True, + label=Label(f'No custom {picture_type} will be used') + )), + CascadingSingleChoiceElement( + name=PARAM_EMBLEM_DEFAULT, + title=Title(f'Use default {picture_type}'), + parameter_form=FixedValue( + value=True, + label=Label(f'"{default_emblem}" will be used as {picture_type}') + )), + CascadingSingleChoiceElement( + name=PARAM_EMBLEM_CUSTOM, + title=Title(f'Use custom {picture_type}'), + parameter_form=String( + custom_validate=(LengthInRange(min_value=1),), + prefill=DefaultValue(default_emblem), + )) + ] + + +def _parameter_form() -> Dictionary: + return Dictionary( + elements={ + PARAM_BGP_AS: DictElement( + parameter_form=CascadingSingleChoice( + title=Title('BGP AS emblem'), + elements=get_emblem_element(EMBLEM_BGP_AS, PICTURE_TYPE_EMBLEM), + prefill=DefaultValue(PARAM_EMBLEM_DEFAULT), + help_text=Help( + 'Here you can change the picture for the BGP-AS object. ' + 'If you use the built-in icons prefix the name with "icon_"' + ), + )), + PARAM_BGP_ID: DictElement( + parameter_form=CascadingSingleChoice( + title=Title('BGP ID emblem'), + elements=get_emblem_element(EMBLEM_BGP_ID, PICTURE_TYPE_EMBLEM), + prefill=DefaultValue(PARAM_EMBLEM_DEFAULT), + help_text=Help( + 'Here you can change the picture for the BGP-ID object. ' + 'If you use the built-in icons prefix the name with "icon_"' + ), + )), + PARAM_BGP_EXT_ANCHOR: DictElement( + parameter_form=SingleChoice( + title=Title('Anchor for external BGP objects'), + elements=[ + SingleChoiceElement(name=PARAM_BGP_EXT_ANCHOR_BOTH, title=Title('Use BGP AS and ID')), + SingleChoiceElement(name=PARAM_BGP_EXT_ANCHOR_AS, title=Title('Use BGP AS only')), + SingleChoiceElement(name=PARAM_BGP_EXT_ANCHOR_ID, title=Title('Use BGP ID only')), + SingleChoiceElement(name=PARAM_BGP_EXT_ANCHOR_NONE, title=Title(f'No anchor object')) + ], + prefill=DefaultValue(PARAM_BGP_EXT_ANCHOR_BOTH), + help_text=Help('Select how BGP elements external to Checkmk will be created'), + )), + PARAM_MAKE_DEFAULT: DictElement( + parameter_form=FixedValue( + title=Title('Make default'), + label=Label('This will be the default topology'), + help_text=Help( + 'Makes the topology the default topology. If there no default topology, this ' + 'topology becomes always the default.' + ), + value=True + )), + PARAM_BGP_SITES_FILTER: DictElement( + parameter_form=CascadingSingleChoice( + title=Title('Filter Checkmk sites'), + elements=[ + CascadingSingleChoiceElement( + name=PARAM_BGP_SITES_INCLUDE, + title=Title('Include checkmk sites'), + parameter_form=List(element_template=String( + title=Title('checkmk site name'), + custom_validate=(DuplicateInList(),), + )) + ), + CascadingSingleChoiceElement( + name=PARAM_BGP_SITES_EXCLUDE, + title=Title('Exclude checkmk sites'), + parameter_form=List(element_template=String( + title=Title('Checkmk site name'), + custom_validate=(DuplicateInList(),), + )) + ) + ], + prefill=DefaultValue(PARAM_BGP_SITES_INCLUDE), + help_text=Help(''), + )), + } + ) + + +rule_spec_bgp_topo = ActiveCheck( + name=RULE_SET_NAME_BGP_TOPOLOGY, + topic=Topic.NETWORKING, + parameter_form=_parameter_form, + title=Title('BGP Topology'), +) diff --git a/source/cmk_addons_plugins/bgp_topology/server_side_calls/bgp_topology.py b/source/cmk_addons_plugins/bgp_topology/server_side_calls/bgp_topology.py new file mode 100644 index 0000000000000000000000000000000000000000..37baa8ea2c21b0148354a7bec7d4de3ecb390e11 --- /dev/null +++ b/source/cmk_addons_plugins/bgp_topology/server_side_calls/bgp_topology.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# License: GNU General Public License v2 +# +# Author: thl-cmk[at]outlook[dot]com +# URL : https://thl-cmk.hopto.org +# Date : 2024-07-20 +# File : bgp_topology/server_side_calls/bgp_topology.py + + +from collections.abc import Iterator, Mapping, Sequence +from pydantic import BaseModel +from typing import Literal + +from cmk.utils import debug +from cmk.server_side_calls.v1 import ( + HostConfig, + Secret, + ActiveCheckCommand, + ActiveCheckConfig, +) + +from cmk_addons.plugins.bgp_topology.constants import ( + ARG_PARSER_ANCHOR, + ARG_PARSER_ANCHOR_AS, + ARG_PARSER_ANCHOR_ID, + ARG_PARSER_EMBLEM_AS, + ARG_PARSER_EMBLEM_ID, + ARG_PARSER_HOST, + ARG_PARSER_MAKE_DEFAULT, + ARG_PARSER_NONE, + ARG_PARSER_SITES_EXCLUDE, + ARG_PARSER_SITES_INCLUDE, + PARAM_BGP_EXT_ANCHOR_AS, + PARAM_BGP_EXT_ANCHOR_BOTH, + PARAM_BGP_EXT_ANCHOR_ID, + PARAM_BGP_EXT_ANCHOR_NONE, + PARAM_BGP_SITES_EXCLUDE, + PARAM_BGP_SITES_INCLUDE, + PARAM_EMBLEM_CUSTOM, + PARAM_EMBLEM_DEFAULT, + PARAM_EMBLEM_NO_EMBLEM, + RULE_SET_NAME_BGP_TOPOLOGY, +) + +EMBLEM = ( + tuple[Literal["default_emblem"], bool] | + tuple[Literal['custom_emblem'], str] | + tuple[Literal['no_emblem'], bool] | + None +) + +FILTER_SITES = ( + tuple[Literal['exclude_sites'], Sequence[str]] | + tuple[Literal['include_sites'], Sequence[str]] +) + +class BgpAnchorType: + anchor_both: str = PARAM_BGP_EXT_ANCHOR_BOTH + anchor_as: str = PARAM_BGP_EXT_ANCHOR_AS + anchor_id: str = PARAM_BGP_EXT_ANCHOR_ID + anchor_none: str = PARAM_BGP_EXT_ANCHOR_NONE + + +class Emblem: + default: str = PARAM_EMBLEM_DEFAULT + custom: str = PARAM_EMBLEM_CUSTOM + no_emblem: str = PARAM_EMBLEM_NO_EMBLEM + + +class Params(BaseModel): + bgp_as: EMBLEM = None + bgp_id: EMBLEM = None + bgp_ext_anchor: str | None = None + make_default: bool | None = None + bgp_filter_sites: FILTER_SITES | None = None + + +__params = { + 'bgp_as': ('custom_emblem', 'icon_cloud'), + 'bgp_id': ('no_emblem', True), + 'bgp_ext_anchor': 'bgp_ext_anchor_both', + 'make_default': True +} +__parsed = Params( + bgp_as=('custom_emblem', 'icon_cloud'), + bgp_id=('no_emblem', True), + bgp_ext_anchor='bgp_ext_anchor_both', + make_default=True +) + + +def _commands_bgp_topology_parser(params: Mapping[str, object]) -> Params: + if debug.enabled(): + print(params) + return Params.model_validate(params) + + +def commands_bgp_topology_arguments( + params: Params, host_config: HostConfig +) -> Iterator[ActiveCheckCommand]: + if debug.enabled(): + pass + # print(host_config) + + args: list[str | Secret] = [] + args += [ARG_PARSER_HOST, host_config.name.replace(' ', '_')] + if params.make_default is True: + args.append(ARG_PARSER_MAKE_DEFAULT) + + if params.bgp_ext_anchor is not None: + match params.bgp_ext_anchor: + case BgpAnchorType.anchor_both: + pass # this is the default + case BgpAnchorType.anchor_as: + args += [ARG_PARSER_ANCHOR, ARG_PARSER_ANCHOR_AS] + case BgpAnchorType.anchor_id: + args += [ARG_PARSER_ANCHOR, ARG_PARSER_ANCHOR_ID] + case BgpAnchorType.anchor_none: + args += [ARG_PARSER_ANCHOR, ARG_PARSER_NONE] + case _: + pass + + if params.bgp_id: + key, value = params.bgp_id + match key: + case Emblem.default: + pass # this is the default + case Emblem.custom: + args += [ARG_PARSER_EMBLEM_ID, value] + case Emblem.no_emblem: + args += [ARG_PARSER_EMBLEM_ID, ARG_PARSER_NONE] + case _: + pass + + if params.bgp_as: + key, value = params.bgp_as + match key: + case Emblem.default: + pass # this is the default + case Emblem.custom: + args += [ARG_PARSER_EMBLEM_AS, value] + case Emblem.no_emblem: + args += [ARG_PARSER_EMBLEM_AS, ARG_PARSER_NONE] + case _: + pass + + if params.bgp_filter_sites: + class FilterMode: + exclude_sites = PARAM_BGP_SITES_EXCLUDE + include_sites = PARAM_BGP_SITES_INCLUDE + + mode, site_list = params.bgp_filter_sites + match mode: + case FilterMode.exclude_sites: + args.append(ARG_PARSER_SITES_EXCLUDE) + args += site_list + case FilterMode.include_sites: + args.append(ARG_PARSER_SITES_INCLUDE) + args += site_list + case _: + pass + + if debug.enabled(): + print(args) + + yield ActiveCheckCommand( + service_description="BGP Topology", + command_arguments=args + ) + + +active_check_bgp_topology = ActiveCheckConfig( + name=RULE_SET_NAME_BGP_TOPOLOGY, + parameter_parser=_commands_bgp_topology_parser, + commands_function=commands_bgp_topology_arguments, +) diff --git a/source/packages/bgp_topology b/source/packages/bgp_topology new file mode 100644 index 0000000000000000000000000000000000000000..7ebfba5d80128a72d9401f749c6656dc87485ae7 --- /dev/null +++ b/source/packages/bgp_topology @@ -0,0 +1,16 @@ +{'author': 'Th.L. (thl-cmk[at]outlook[dot]com)', + 'description': 'Active check to create the BGP peer topology\n', + 'download_url': 'https://thl-cmk.hopto.org', + 'files': {'cmk_addons_plugins': ['bgp_topology/constants.py', + 'bgp_topology/lib/bgp_topology.py', + 'bgp_topology/lib/utils.py', + 'bgp_topology/libexec/check_bgp_topology', + 'bgp_topology/rulesets/bgp_topology.py', + 'bgp_topology/server_side_calls/bgp_topology.py', + 'bgp_topology/graphing/bgp_topology.py']}, + 'name': 'bgp_topology', + 'title': 'BGP peer topology', + 'version': '0.0.1-20240722', + 'version.min_required': '2.3.0b1', + 'version.packaged': 'cmk-mkp-tool 0.2.0', + 'version.usable_until': '2.4.0b1'}