├── mktxp ├── __init__.py ├── cli │ ├── __init__.py │ ├── checks │ │ ├── __init__.py │ │ └── chk_pv.py │ ├── config │ │ ├── __init__.py │ │ └── _mktxp.conf │ ├── output │ │ ├── __init__.py │ │ ├── conn_stats_out.py │ │ ├── dhcp_out.py │ │ ├── capsman_out.py │ │ ├── wifi_out.py │ │ └── netwatch_out.py │ └── dispatch.py ├── flow │ ├── __init__.py │ ├── processor │ │ └── __init__.py │ └── router_entries_handler.py ├── utils │ └── __init__.py ├── collector │ ├── __init__.py │ ├── mktxp_collector.py │ ├── identity_collector.py │ ├── package_collector.py │ ├── container_collector.py │ ├── public_ip_collector.py │ ├── gre_collector.py │ ├── ipip_collector.py │ ├── neighbor_collector.py │ ├── dns_collector.py │ ├── eoip_collector.py │ ├── user_collector.py │ ├── connection_collector.py │ ├── certificate_collector.py │ ├── poe_collector.py │ ├── pool_collector.py │ ├── dhcp_collector.py │ ├── kid_control_device_collector.py │ ├── bgp_collector.py │ ├── routing_stats_collector.py │ ├── bandwidth_collector.py │ ├── resource_collector.py │ ├── queue_collector.py │ ├── route_collector.py │ ├── bfd_collector.py │ ├── lte_collector.py │ ├── ipsec_collector.py │ ├── wlan_collector.py │ ├── switch_collector.py │ ├── capsman_collector.py │ └── address_list_collector.py └── datasource │ ├── __init__.py │ ├── dns_ds.py │ ├── mktxp_ds.py │ ├── public_ip_ds.py │ ├── identity_ds.py │ ├── user_ds.py │ ├── neighbor_ds.py │ ├── certificate_ds.py │ ├── switch_ds.py │ ├── bfd_ds.py │ ├── container_ds.py │ ├── kid_control_device_ds.py │ ├── bgp_ds.py │ ├── routing_stats_ds.py │ ├── ipsec_ds.py │ ├── route_ds.py │ ├── health_ds.py │ ├── package_ds.py │ ├── netwatch_ds.py │ ├── pool_ds.py │ ├── queue_ds.py │ ├── system_resource_ds.py │ ├── wireless_ds.py │ ├── routerboard_ds.py │ ├── dhcp_ds.py │ ├── address_list_ds.py │ ├── firewall_ds.py │ ├── poe_ds.py │ ├── connection_ds.py │ └── base_ds.py ├── tests ├── __init__.py ├── utils │ ├── __init__.py │ └── test_utils.py ├── flow │ ├── test_router_entries_handler.py │ ├── test_collector_handler.py │ ├── test_router_connection.py │ ├── test_base_proc.py │ └── test_router_entry.py └── collector │ ├── test_connection_collector.py │ └── test_user_collector.py ├── .dockerignore ├── tox.ini ├── LICENSE ├── Dockerfile ├── .github └── workflows │ ├── tests.yaml │ └── build-and-push.yaml ├── deploy └── kubernetes │ └── deployment.yaml ├── .gitignore └── setup.py /mktxp/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mktxp/cli/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mktxp/flow/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mktxp/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mktxp/cli/checks/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mktxp/cli/config/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mktxp/cli/output/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mktxp/collector/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mktxp/datasource/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mktxp/flow/processor/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .gitignore 3 | .dockerignore 4 | Dockerfile 5 | k8s 6 | build 7 | dist 8 | mktxp.egg-info 9 | tests/ 10 | .github/ 11 | deploy/ 12 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py3{13,12,11,10,9} 4 | skip_missing_interpreters = true 5 | 6 | [testenv] 7 | package = wheel 8 | wheel_build_env = .pkg 9 | envtmpdir = {toxworkdir}/tmp/{envname} 10 | constrain_package_deps = true 11 | use_frozen_constraints = true 12 | deps = 13 | pytest 14 | 15 | commands = pytest -v --tb=short --basetemp={envtmpdir} {posargs} 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ## Copyright (c) 2020 Arseniy Kuznetsov 2 | ## 3 | ## This program is free software; you can redistribute it and/or 4 | ## modify it under the terms of the GNU General Public License 5 | ## as published by the Free Software Foundation; either version 2 6 | ## of the License, or (at your option) any later version. 7 | ## 8 | ## This program is distributed in the hope that it will be useful, 9 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | ## GNU General Public License for more details. -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3-alpine 2 | 3 | LABEL org.opencontainers.image.source=https://github.com/akpw/mktxp 4 | LABEL org.opencontainers.image.description="MKTXP is a Prometheus Exporter for Mikrotik RouterOS devices" 5 | LABEL org.opencontainers.image.licenses=GPLv2+ 6 | 7 | RUN adduser -u 1000 -D mktxp 8 | RUN apk add nano 9 | 10 | # Create standard config directory with proper ownership 11 | RUN mkdir -p /etc/mktxp && chown mktxp:mktxp /etc/mktxp 12 | 13 | WORKDIR /mktxp 14 | COPY . . 15 | RUN pip install ./ 16 | 17 | EXPOSE 49090 18 | 19 | USER mktxp 20 | ENV PYTHONUNBUFFERED=1 21 | CMD ["mktxp", "export"] 22 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: 3 | push: 4 | branches: [main] 5 | paths-ignore: [ '*.md', '*.rst'] 6 | pull_request: 7 | paths-ignore: [ '*.md', '*.rst' ] 8 | jobs: 9 | tests: 10 | name: ${{ matrix.name || matrix.python }} 11 | runs-on: ${{ matrix.os || 'ubuntu-latest' }} 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | include: 16 | - {python: '3.13'} 17 | - {python: '3.12'} 18 | - {python: '3.11'} 19 | - {python: '3.10'} 20 | - {python: '3.9'} 21 | steps: 22 | - uses: actions/checkout@v4.2.2 23 | - uses: actions/setup-python@v5.4.0 24 | with: 25 | python-version: ${{ matrix.python }} 26 | allow-prereleases: true 27 | # We need a requirements.txt or pyproject.toml 28 | # cache: pip 29 | - run: pip install tox 30 | - run: tox run -e ${{ matrix.tox || format('py{0}', matrix.python) }} 31 | -------------------------------------------------------------------------------- /deploy/kubernetes/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: mktxp-exporter 5 | spec: 6 | selector: 7 | matchLabels: 8 | app: mktxp-exporter 9 | template: 10 | metadata: 11 | labels: 12 | app: mktxp-exporter 13 | spec: 14 | containers: 15 | - name: mktxp-exporter 16 | image: ghcr.io/akpw/mktxp:latest 17 | args: 18 | - --cfg-dir 19 | - /mktxp_config 20 | - export 21 | resources: 22 | limits: 23 | memory: "512Mi" 24 | cpu: "500m" 25 | volumeMounts: 26 | - name: mktxp-credentials 27 | mountPath: /mktxp_config 28 | ports: 29 | - containerPort: 49090 30 | volumes: 31 | - name: mktxp-credentials 32 | secret: 33 | secretName: mktxp-credentials 34 | --- 35 | apiVersion: v1 36 | kind: Service 37 | metadata: 38 | name: mktxp-exporter 39 | annotations: 40 | prometheus.io/port: "49090" 41 | prometheus.io/scrape: "true" 42 | spec: 43 | selector: 44 | app: mktxp-exporter 45 | ports: 46 | - port: 49090 47 | targetPort: 49090 48 | -------------------------------------------------------------------------------- /mktxp/datasource/dns_ds.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | # Copyright (c) 2020 Arseniy Kuznetsov 3 | # 4 | # This program is free software; you can redistribute it and/or 5 | # modify it under the terms of the GNU General Public License 6 | # as published by the Free Software Foundation; either version 2 7 | # of the License, or (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | 14 | 15 | from mktxp.datasource.base_ds import BaseDSProcessor 16 | 17 | 18 | class DNSDataSource: 19 | def metric_records(router_entry): 20 | try: 21 | router_records = router_entry.api_connection.router_api().get_resource('/ip/dns').get() 22 | dns_records = BaseDSProcessor.trimmed_records(router_entry, router_records=router_records) 23 | return dns_records[0] if dns_records else None 24 | except Exception as exc: 25 | print(f'Error getting DNS info from router {router_entry.router_name}@{router_entry.config_entry.hostname}: {exc}') 26 | return None 27 | -------------------------------------------------------------------------------- /mktxp/collector/mktxp_collector.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | 15 | from mktxp.collector.base_collector import BaseCollector 16 | from mktxp.datasource.mktxp_ds import MKTXPMetricsDataSource 17 | 18 | 19 | class MKTXPCollector(BaseCollector): 20 | ''' System Identity Metrics collector 21 | ''' 22 | @staticmethod 23 | def collect(router_entry): 24 | mktxp_records = MKTXPMetricsDataSource.metric_records(router_entry) 25 | if mktxp_records: 26 | mktxp_duration_metric = BaseCollector.counter_collector('collection_time', 'Total time spent collecting metrics in milliseconds', mktxp_records, 'duration', ['name']) 27 | yield mktxp_duration_metric 28 | 29 | 30 | -------------------------------------------------------------------------------- /mktxp/datasource/mktxp_ds.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | 15 | from mktxp.datasource.base_ds import BaseDSProcessor 16 | 17 | 18 | class MKTXPMetricsDataSource: 19 | ''' MKTXP Metrics data provider 20 | ''' 21 | @staticmethod 22 | def metric_records(router_entry): 23 | mktxp_records = [] 24 | for key in router_entry.time_spent.keys(): 25 | mktxp_records.append({'name': key, 'duration': router_entry.time_spent[key]}) 26 | 27 | # translation rules 28 | translation_table = {'duration': lambda d: d*1000} 29 | return BaseDSProcessor.trimmed_records(router_entry, router_records = mktxp_records, translation_table = translation_table) 30 | -------------------------------------------------------------------------------- /mktxp/collector/identity_collector.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | 15 | from mktxp.collector.base_collector import BaseCollector 16 | from mktxp.datasource.identity_ds import IdentityMetricsDataSource 17 | 18 | 19 | class IdentityCollector(BaseCollector): 20 | ''' System Identity Metrics collector 21 | ''' 22 | @staticmethod 23 | def collect(router_entry): 24 | identity_labels = ['name'] 25 | identity_records = IdentityMetricsDataSource.metric_records(router_entry, metric_labels = identity_labels) 26 | if identity_records: 27 | identity_metrics = BaseCollector.info_collector('system_identity', 'System identity', identity_records, identity_labels) 28 | yield identity_metrics 29 | 30 | -------------------------------------------------------------------------------- /mktxp/datasource/public_ip_ds.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | from mktxp.datasource.base_ds import BaseDSProcessor 15 | 16 | class PublicIPAddressDatasource: 17 | @staticmethod 18 | def metric_records(router_entry, *, metric_labels = None): 19 | if metric_labels is None: 20 | metric_labels = [] 21 | 22 | try: 23 | records = router_entry.api_connection.router_api().get_resource('/ip/cloud/').get() 24 | return BaseDSProcessor.trimmed_records(router_entry, router_records=records, metric_labels = metric_labels) 25 | except Exception as exc: 26 | print(f'Error public IP address info from router {router_entry.router_name}@{router_entry.config_entry.hostname}: {exc}') 27 | return None -------------------------------------------------------------------------------- /mktxp/collector/package_collector.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | 15 | from mktxp.collector.base_collector import BaseCollector 16 | from mktxp.datasource.package_ds import PackageMetricsDataSource 17 | 18 | 19 | class PackageCollector(BaseCollector): 20 | '''Installed Packages collector''' 21 | @staticmethod 22 | def collect(router_entry): 23 | if not router_entry.config_entry.installed_packages: 24 | return 25 | 26 | package_labels = ['name', 'version', 'build_time', 'disabled'] 27 | package_records = PackageMetricsDataSource.metric_records(router_entry, metric_labels=package_labels) 28 | if package_records: 29 | package_metrics = BaseCollector.info_collector('installed_packages', 'Installed Packages', package_records, package_labels) 30 | yield package_metrics 31 | 32 | -------------------------------------------------------------------------------- /mktxp/collector/container_collector.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | # Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | # This program is free software; you can redistribute it and/or 5 | # modify it under the terms of the GNU General Public License 6 | # as published by the Free Software Foundation; either version 2 7 | # of the License, or (at your option) any later version. 8 | ## 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | 14 | 15 | from mktxp.collector.base_collector import BaseCollector 16 | from mktxp.datasource.container_ds import ContainerDataSource 17 | from mktxp.datasource.package_ds import PackageMetricsDataSource 18 | 19 | 20 | class ContainerCollector(BaseCollector): 21 | '''Neighbor Collector''' 22 | 23 | @staticmethod 24 | def collect(router_entry): 25 | if router_entry.config_entry.container and PackageMetricsDataSource.is_package_installed(router_entry, package_name='container'): 26 | metric_labels = ['name', 'repo', 'os', 'arch', 'status'] 27 | records = ContainerDataSource.metric_records(router_entry, metric_labels=metric_labels) 28 | metrics = BaseCollector.info_collector('container', 'Containers', records, metric_labels=metric_labels) 29 | yield metrics 30 | -------------------------------------------------------------------------------- /mktxp/datasource/identity_ds.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | 15 | from mktxp.datasource.base_ds import BaseDSProcessor 16 | 17 | 18 | class IdentityMetricsDataSource: 19 | ''' Identity Metrics data provider 20 | ''' 21 | @staticmethod 22 | def metric_records(router_entry, *, metric_labels = None): 23 | if metric_labels is None: 24 | metric_labels = [] 25 | try: 26 | identity_records = router_entry.api_connection.router_api().get_resource('/system/identity').get() 27 | return BaseDSProcessor.trimmed_records(router_entry, router_records = identity_records, metric_labels = metric_labels) 28 | except Exception as exc: 29 | print(f'Error getting system identity info from router {router_entry.router_name}@{router_entry.config_entry.hostname}: {exc}') 30 | return None 31 | -------------------------------------------------------------------------------- /mktxp/datasource/user_ds.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | 15 | from mktxp.datasource.base_ds import BaseDSProcessor 16 | 17 | 18 | class UserMetricsDataSource: 19 | ''' Active Users Metrics data provider 20 | ''' 21 | @staticmethod 22 | def metric_records(router_entry, *, metric_labels = None): 23 | if metric_labels is None: 24 | metric_labels = [] 25 | try: 26 | active_users_records = router_entry.api_connection.router_api().get_resource('/user/active/').get() 27 | return BaseDSProcessor.trimmed_records(router_entry, router_records = active_users_records, metric_labels = metric_labels) 28 | except Exception as exc: 29 | print(f'Error getting system resource info from router {router_entry.router_name}@{router_entry.config_entry.hostname}: {exc}') 30 | return None 31 | -------------------------------------------------------------------------------- /mktxp/datasource/neighbor_ds.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | # Copyright (c) 2020 Arseniy Kuznetsov 3 | # 4 | # This program is free software; you can redistribute it and/or 5 | # modify it under the terms of the GNU General Public License 6 | # as published by the Free Software Foundation; either version 2 7 | # of the License, or (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | 14 | 15 | from mktxp.datasource.base_ds import BaseDSProcessor 16 | 17 | 18 | class NeighborDataSource: 19 | def metric_records(router_entry, metric_labels, ipv6=False): 20 | 21 | metric_labels = metric_labels or [] 22 | router_records = [] 23 | 24 | try: 25 | if ipv6: 26 | router_records = router_entry.api_connection.router_api().get_resource(f'/ipv6/neighbor').get(status='reachable') 27 | else: 28 | router_records = router_entry.api_connection.router_api().get_resource(f'/ip/neighbor').get() 29 | return BaseDSProcessor.trimmed_records(router_entry, router_records=router_records, metric_labels=metric_labels) 30 | except Exception as exc: 31 | print(f'Error getting Neighbors info from router {router_entry.router_name}@{router_entry.config_entry.hostname}: {exc}') 32 | return None 33 | -------------------------------------------------------------------------------- /mktxp/cli/checks/chk_pv.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding=utf8 3 | ## Copyright (c) 2020 Arseniy Kuznetsov 4 | ## 5 | ## This program is free software; you can redistribute it and/or 6 | ## modify it under the terms of the GNU General Public License 7 | ## as published by the Free Software Foundation; either version 2 8 | ## of the License, or (at your option) any later version. 9 | ## 10 | ## This program is distributed in the hope that it will be useful, 11 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | ## GNU General Public License for more details. 14 | 15 | ''' Python version check 16 | ''' 17 | 18 | from __future__ import print_function 19 | 20 | import sys 21 | 22 | def check_version(): 23 | if sys.version_info.major < 3: 24 | print(\ 25 | ''' 26 | Mikrotik Prometheus Exporter requires 27 | Python version 3.8 or later. 28 | 29 | You can create an isolated Python 3.8 environment 30 | with the virtualenv tool: 31 | http://docs.python-guide.org/en/latest/dev/virtualenvs 32 | 33 | ''') 34 | sys.exit(0) 35 | elif sys.version_info.major == 3 and sys.version_info.minor < 8: 36 | print(\ 37 | ''' 38 | 39 | Mikrotik Prometheus Exporter requires 40 | Python version 3.8 or later. 41 | 42 | Please upgrade to the latest Python 3.x version. 43 | 44 | ''') 45 | sys.exit(0) 46 | 47 | # check 48 | check_version() 49 | -------------------------------------------------------------------------------- /mktxp/datasource/certificate_ds.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | 15 | from mktxp.datasource.base_ds import BaseDSProcessor 16 | 17 | 18 | class CertificateMetricsDataSource: 19 | '''Certificates Metrics data provider 20 | ''' 21 | @staticmethod 22 | def metric_records(router_entry, *, metric_labels = None, translation_table=None): 23 | if metric_labels is None: 24 | metric_labels = [] 25 | try: 26 | certificates_records = router_entry.api_connection.router_api().get_resource('/certificate').call('print', {'detail':''}) 27 | return BaseDSProcessor.trimmed_records(router_entry, router_records = certificates_records, 28 | metric_labels = metric_labels, translation_table=translation_table) 29 | except Exception as exc: 30 | print(f'Error getting certificates info from router {router_entry.router_name}@{router_entry.config_entry.hostname}: {exc}') 31 | return None -------------------------------------------------------------------------------- /mktxp/collector/public_ip_collector.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | 15 | from mktxp.collector.base_collector import BaseCollector 16 | from mktxp.datasource.public_ip_ds import PublicIPAddressDatasource 17 | 18 | 19 | class PublicIPAddressCollector(BaseCollector): 20 | '''Public IP address collector''' 21 | @staticmethod 22 | def collect(router_entry): 23 | if not router_entry.config_entry.public_ip: 24 | return 25 | 26 | address_labels = ['public_address', 'public_address_ipv6', 'dns_name'] 27 | address_records = PublicIPAddressDatasource.metric_records(router_entry, metric_labels=address_labels) 28 | 29 | if address_records: 30 | for address_record in address_records: 31 | if not 'dns_name' in address_record: 32 | address_record['dns_name'] = 'ddns disabled' 33 | 34 | address_metrics = BaseCollector.info_collector('public_ip_address', 'Public IP address', address_records, address_labels) 35 | yield address_metrics 36 | -------------------------------------------------------------------------------- /mktxp/datasource/switch_ds.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | 15 | from mktxp.datasource.base_ds import BaseDSProcessor 16 | 17 | 18 | class SwitchPortMetricsDataSource: 19 | ''' Switch Port Metrics data provider 20 | ''' 21 | @staticmethod 22 | def metric_records(router_entry, *, metric_labels = None, translation_table = None): 23 | if metric_labels is None: 24 | metric_labels = [] 25 | try: 26 | active_users_records = router_entry.api_connection.router_api().get_resource('/interface/ethernet/switch/port').call('print', {'stats': 'detail'}) 27 | return BaseDSProcessor.trimmed_records(router_entry, router_records = active_users_records, 28 | metric_labels = metric_labels, translation_table = translation_table) 29 | except Exception as exc: 30 | print(f'Error getting system resource info from router {router_entry.router_name}@{router_entry.config_entry.hostname}: {exc}') 31 | return None 32 | -------------------------------------------------------------------------------- /mktxp/datasource/bfd_ds.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | 15 | from mktxp.datasource.base_ds import BaseDSProcessor 16 | from mktxp.datasource.system_resource_ds import SystemResourceMetricsDataSource 17 | 18 | 19 | class BFDMetricsDataSource: 20 | """Bidirectional Forwarding Detection (BFD) data provider""" 21 | @staticmethod 22 | def metric_records(router_entry, *, metric_labels = None, translation_table = None): 23 | if metric_labels is None: 24 | metric_labels = [] 25 | try: 26 | bfd_records = router_entry.api_connection.router_api().get_resource("/routing/bfd/session").get() 27 | return BaseDSProcessor.trimmed_records( 28 | router_entry, 29 | router_records=bfd_records, 30 | metric_labels=metric_labels, 31 | translation_table=translation_table 32 | ) 33 | except Exception as exc: 34 | print(f"Error getting BFD sessions info from router {router_entry.router_name}@{router_entry.config_entry.hostname}: {exc}") 35 | return None 36 | -------------------------------------------------------------------------------- /mktxp/collector/gre_collector.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2024 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | from mktxp.collector.base_collector import BaseCollector 15 | from mktxp.datasource.interface_ds import InterfaceMetricsDataSource 16 | from mktxp.utils.utils import parse_mkt_uptime 17 | 18 | 19 | class GRECollector(BaseCollector): 20 | """ GRE Metrics collector 21 | """ 22 | @staticmethod 23 | def collect(router_entry): 24 | if not router_entry.config_entry.gre: 25 | return 26 | 27 | default_labels = ['name', 'local_address', 'remote_address'] 28 | monitor_records = InterfaceMetricsDataSource.metric_records( 29 | router_entry, 30 | kind='gre', 31 | additional_proplist=['mtu', 'actual-mtu', 'local-address', 'remote-address'], 32 | ) 33 | 34 | if monitor_records: 35 | yield BaseCollector.gauge_collector( 36 | 'interface_mtu', 37 | 'Current used MTU for this interface', 38 | monitor_records, 39 | metric_key='actual_mtu', 40 | metric_labels=default_labels + ['mtu'] 41 | ) 42 | -------------------------------------------------------------------------------- /mktxp/collector/ipip_collector.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2024 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | from mktxp.collector.base_collector import BaseCollector 15 | from mktxp.datasource.interface_ds import InterfaceMetricsDataSource 16 | from mktxp.utils.utils import parse_mkt_uptime 17 | 18 | 19 | class IPIPCollector(BaseCollector): 20 | """ IPIP Metrics collector 21 | """ 22 | @staticmethod 23 | def collect(router_entry): 24 | if not router_entry.config_entry.ipip: 25 | return 26 | 27 | default_labels = ['name', 'local_address', 'remote_address'] 28 | interface_records = InterfaceMetricsDataSource.metric_records( 29 | router_entry, 30 | kind='ipip', 31 | additional_proplist=['mtu', 'actual-mtu', 'local-address', 'remote-address'], 32 | ) 33 | 34 | if interface_records: 35 | yield BaseCollector.gauge_collector( 36 | 'interface_mtu', 37 | 'Current used MTU for this interface', 38 | interface_records, 39 | metric_key='actual_mtu', 40 | metric_labels=default_labels + ['mtu'] 41 | ) 42 | -------------------------------------------------------------------------------- /mktxp/collector/neighbor_collector.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | # Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | # This program is free software; you can redistribute it and/or 5 | # modify it under the terms of the GNU General Public License 6 | # as published by the Free Software Foundation; either version 2 7 | # of the License, or (at your option) any later version. 8 | ## 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | 14 | 15 | from mktxp.collector.base_collector import BaseCollector 16 | from mktxp.datasource.neighbor_ds import NeighborDataSource 17 | 18 | 19 | class NeighborCollector(BaseCollector): 20 | '''Neighbor Collector''' 21 | 22 | @staticmethod 23 | def collect(router_entry): 24 | if router_entry.config_entry.neighbor: 25 | metric_labels = ['address', 'interface', 'mac_address', 'identity'] 26 | records = NeighborDataSource.metric_records(router_entry, metric_labels=metric_labels) 27 | metrics = BaseCollector.info_collector('neighbor', 'Reachable neighbors (IPv4)', records, metric_labels=metric_labels) 28 | yield metrics 29 | 30 | if router_entry.config_entry.ipv6_neighbor: 31 | metric_labels = ['address', 'interface', 'mac_address', 'status', 'comment'] 32 | records = NeighborDataSource.metric_records(router_entry, metric_labels=metric_labels) 33 | metrics = BaseCollector.info_collector('ipv6_neighbor', 'Reachable neighbors (IPv6)', records, metric_labels=metric_labels) 34 | yield metrics 35 | -------------------------------------------------------------------------------- /mktxp/datasource/container_ds.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | # Copyright (c) 2020 Arseniy Kuznetsov 3 | # 4 | # This program is free software; you can redistribute it and/or 5 | # modify it under the terms of the GNU General Public License 6 | # as published by the Free Software Foundation; either version 2 7 | # of the License, or (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | 14 | 15 | from mktxp.datasource.base_ds import BaseDSProcessor 16 | from mktxp.flow.processor.output import BaseOutputProcessor 17 | 18 | 19 | class ContainerDataSource: 20 | @staticmethod 21 | def metric_records(router_entry, metric_labels): 22 | 23 | metric_labels = metric_labels or [] 24 | router_records = [] 25 | 26 | try: 27 | router_records = router_entry.api_connection.router_api().get_resource(f'/container').get() 28 | for record in router_records: 29 | if 'comment' in record: 30 | # Format name with comment using centralized function 31 | record['name'] = BaseOutputProcessor.format_interface_name( 32 | record['name'], 33 | record['comment'], 34 | router_entry.config_entry.interface_name_format 35 | ) 36 | return BaseDSProcessor.trimmed_records(router_entry, router_records=router_records, metric_labels=metric_labels) 37 | except Exception as exc: 38 | print(f'Error getting Neighbors info from router {router_entry.router_name}@{router_entry.config_entry.hostname}: {exc}') 39 | return None 40 | -------------------------------------------------------------------------------- /mktxp/datasource/kid_control_device_ds.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | 15 | from mktxp.datasource.base_ds import BaseDSProcessor 16 | 17 | 18 | class KidDeviceMetricsDataSource: 19 | """ Kid-control device Metrics data provider 20 | """ 21 | 22 | @staticmethod 23 | def metric_records(router_entry, *, metric_labels=None, translation_table=None, cli_output=False): 24 | if metric_labels is None: 25 | metric_labels = [] 26 | try: 27 | device_records = [] 28 | records = router_entry.api_connection.router_api().get_resource('/ip/kid-control/device').get() 29 | for record in records: 30 | # If cli_output is True (called from print command), show all devices 31 | # Otherwise, respect the configuration settings 32 | if cli_output or record.get('user') or router_entry.config_entry.kid_control_dynamic: 33 | device_records.append(record) 34 | return BaseDSProcessor.trimmed_records(router_entry, router_records=device_records, metric_labels=metric_labels, translation_table=translation_table) 35 | except Exception as exc: 36 | print( 37 | f'Error getting Kid-control device info from router {router_entry.router_name}@{router_entry.config_entry.hostname}: {exc}') 38 | return None 39 | -------------------------------------------------------------------------------- /mktxp/datasource/bgp_ds.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | 15 | from mktxp.datasource.base_ds import BaseDSProcessor 16 | from mktxp.datasource.system_resource_ds import SystemResourceMetricsDataSource 17 | from mktxp.utils.utils import routerOS7_version 18 | 19 | class BGPMetricsDataSource: 20 | ''' Wireless Metrics data provider 21 | ''' 22 | @staticmethod 23 | def metric_records(router_entry, *, metric_labels = None, translation_table = None): 24 | if metric_labels is None: 25 | metric_labels = [] 26 | try: 27 | bgp_routing_path = '/routing/bgp/session' 28 | 29 | # legacy 6.x versions use a different path 30 | ver = SystemResourceMetricsDataSource.os_version(router_entry) 31 | if not routerOS7_version(ver): 32 | bgp_routing_path = '/routing/bgp/peer' 33 | 34 | bgp_records = router_entry.api_connection.router_api().get_resource(bgp_routing_path).get() 35 | return BaseDSProcessor.trimmed_records(router_entry, router_records = bgp_records, metric_labels = metric_labels, translation_table = translation_table) 36 | except Exception as exc: 37 | print(f'Error getting BGP sessions info from router {router_entry.router_name}@{router_entry.config_entry.hostname}: {exc}') 38 | return None 39 | 40 | -------------------------------------------------------------------------------- /tests/flow/test_router_entries_handler.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from unittest.mock import Mock, patch 3 | from mktxp.flow.router_entries_handler import RouterEntriesHandler 4 | 5 | @patch('mktxp.flow.router_entries_handler.config_handler') 6 | def test_set_child_entries_with_invalid_remote_dhcp_entry(mock_config_handler, capsys): 7 | # Test case: remote_dhcp_entry is a boolean - this should be treated as an unregistered entry 8 | mock_router_entry = Mock() 9 | mock_router_entry.router_name = "TestRouter" 10 | mock_router_entry.config_entry.remote_dhcp_entry = True 11 | mock_router_entry.config_entry.remote_capsman_entry = None 12 | 13 | # Mock registered_entry to return None for boolean values (as it would in real ConfigObj) 14 | mock_config_handler.registered_entry.return_value = None 15 | 16 | RouterEntriesHandler._set_child_entries(mock_router_entry) 17 | 18 | captured = capsys.readouterr() 19 | assert "Error in configuration for TestRouter: remote_dhcp_entry must a name of another router entry or 'None', but it is 'True'. Ignoring." in captured.out 20 | mock_config_handler.registered_entry.assert_called_with(True) 21 | 22 | @patch('mktxp.flow.router_entries_handler.config_handler') 23 | def test_set_child_entries_with_valid_remote_dhcp_entry(mock_config_handler, capsys): 24 | # Test case: remote_dhcp_entry is a valid string 25 | mock_router_entry = Mock() 26 | mock_router_entry.router_name = "TestRouter" 27 | mock_router_entry.config_entry.remote_dhcp_entry = "AnotherRouter" 28 | mock_router_entry.config_entry.remote_capsman_entry = None 29 | mock_config_handler.registered_entry.return_value = True 30 | 31 | with patch('mktxp.flow.router_entries_handler.RouterEntry') as mock_router_entry_class: 32 | RouterEntriesHandler._set_child_entries(mock_router_entry) 33 | 34 | mock_config_handler.registered_entry.assert_called_with("AnotherRouter") 35 | mock_router_entry_class.assert_called_with("AnotherRouter") 36 | -------------------------------------------------------------------------------- /mktxp/collector/dns_collector.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | # Copyright (c) 2020 Arseniy Kuznetsov 3 | # 4 | # This program is free software; you can redistribute it and/or 5 | # modify it under the terms of the GNU General Public License 6 | # as published by the Free Software Foundation; either version 2 7 | # of the License, or (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | 14 | 15 | from mktxp.cli.config.config import MKTXPConfigKeys 16 | from mktxp.collector.base_collector import BaseCollector 17 | from mktxp.datasource.dns_ds import DNSDataSource 18 | 19 | 20 | class DNSCollector(BaseCollector): 21 | '''Dns Collector''' 22 | 23 | @staticmethod 24 | def collect(router_entry): 25 | allowed_properties = {'cache_size', 'cache_used'} 26 | if not router_entry.config_entry.dns: 27 | return 28 | 29 | record = DNSDataSource.metric_records(router_entry) 30 | 31 | if not record: 32 | return 33 | 34 | keys = list(record.keys()) 35 | metrics = [] 36 | 37 | for key in keys: 38 | if key not in allowed_properties: 39 | continue 40 | 41 | metric_record = { 42 | MKTXPConfigKeys.ROUTERBOARD_NAME: router_entry.router_id[MKTXPConfigKeys.ROUTERBOARD_NAME], 43 | MKTXPConfigKeys.ROUTERBOARD_ADDRESS: router_entry.router_id[MKTXPConfigKeys.ROUTERBOARD_ADDRESS], 44 | 'property': key, 45 | 'value': int(record[key]) * 1024 46 | } 47 | metrics.append(metric_record) 48 | 49 | yield BaseCollector.gauge_collector( 50 | 'dns_info', 51 | 'DNS info', 52 | metrics, 53 | 'value', 54 | metric_labels=['property'] 55 | ) 56 | -------------------------------------------------------------------------------- /mktxp/datasource/routing_stats_ds.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | from mktxp.datasource.base_ds import BaseDSProcessor 15 | from mktxp.datasource.system_resource_ds import SystemResourceMetricsDataSource 16 | from mktxp.utils.utils import routerOS7_version 17 | 18 | 19 | class RoutingStatsMetricsDataSource: 20 | ''' Routing Stats data provider 21 | ''' 22 | @staticmethod 23 | def metric_records(router_entry, *, metric_labels = None, translation_table = None): 24 | if metric_labels is None: 25 | metric_labels = [] 26 | try: 27 | routing_stats = '/routing/stats/process' 28 | 29 | # legacy 6.x versions are untested 30 | ver = SystemResourceMetricsDataSource.os_version(router_entry) 31 | if not routerOS7_version(ver): 32 | raise Exception("Routing stats for legacy 6.x versions are not supported at the moment") 33 | 34 | routing_stats_records = router_entry.api_connection.router_api().get_resource(routing_stats).get() 35 | return BaseDSProcessor.trimmed_records(router_entry, router_records = routing_stats_records, metric_labels = metric_labels, translation_table = translation_table) 36 | except Exception as exc: 37 | print(f'Error getting routing stats sessions info from router {router_entry.router_name}@{router_entry.config_entry.hostname}: {exc}') 38 | return None 39 | 40 | -------------------------------------------------------------------------------- /mktxp/collector/eoip_collector.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2024 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | from mktxp.collector.base_collector import BaseCollector 15 | from mktxp.datasource.interface_ds import InterfaceMetricsDataSource 16 | from mktxp.utils.utils import parse_mkt_uptime 17 | 18 | 19 | class EOIPCollector(BaseCollector): 20 | """ EoIP Metrics collector 21 | """ 22 | @staticmethod 23 | def collect(router_entry): 24 | if not router_entry.config_entry.eoip: 25 | return 26 | 27 | default_labels = ['name', 'local_address', 'remote_address', 'tunnel_id'] 28 | monitor_records = InterfaceMetricsDataSource.metric_records( 29 | router_entry, 30 | kind='eoip', 31 | additional_proplist=['actual-mtu', 'l2mtu', 'local-address', 'mtu', 'remote-address', 'tunnel-id'], 32 | ) 33 | 34 | if monitor_records: 35 | yield BaseCollector.gauge_collector( 36 | 'interface_l2mtu', 37 | 'Current used layer 2 mtu for this interface', 38 | monitor_records, 39 | metric_key='actual_mtu', 40 | metric_labels=default_labels 41 | ) 42 | 43 | yield BaseCollector.gauge_collector( 44 | 'interface_mtu', 45 | 'Current used mut for this interface', 46 | monitor_records, 47 | metric_key='actual_mtu', 48 | metric_labels=default_labels + ['mtu'] 49 | ) 50 | -------------------------------------------------------------------------------- /mktxp/collector/user_collector.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | from datetime import datetime 15 | from mktxp.collector.base_collector import BaseCollector 16 | from mktxp.datasource.user_ds import UserMetricsDataSource 17 | 18 | class UserCollector(BaseCollector): 19 | '''Active Users collector''' 20 | @staticmethod 21 | def collect(router_entry): 22 | if not router_entry.config_entry.user: 23 | return 24 | 25 | user_labels = ['name', 'when', 'address', 'via', 'group'] 26 | user_records = UserMetricsDataSource.metric_records(router_entry, metric_labels=user_labels) 27 | 28 | if user_records: 29 | for record in user_records: 30 | if 'when' in record: 31 | try: 32 | dt = datetime.strptime(record['when'], "%Y-%m-%d %H:%M:%S") 33 | record['when'] = int(dt.timestamp()) 34 | except ValueError: 35 | # Use current timestamp as fallback if parsing fails 36 | record['when'] = int(datetime.now().timestamp()) 37 | 38 | user_labels.remove('when') 39 | 40 | # Auto de-duplicate the records from the scrapes via API silently 41 | user_metrics = BaseCollector.gauge_collector('active_users_info', 'Active Users', user_records, 'when', user_labels, verbose_reporting = False) 42 | yield user_metrics 43 | -------------------------------------------------------------------------------- /mktxp/datasource/ipsec_ds.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | 15 | from mktxp.datasource.base_ds import BaseDSProcessor 16 | from mktxp.flow.processor.output import BaseOutputProcessor 17 | 18 | 19 | class IPSecMetricsDataSource: 20 | """IPSec Metrics data provider""" 21 | @staticmethod 22 | def metric_records(router_entry, *, metric_labels=None, translation_table=None): 23 | if metric_labels is None: 24 | metric_labels = [] 25 | 26 | try: 27 | ipsec_records = router_entry.api_connection.router_api().get_resource('/ip/ipsec/active-peers').call('print', {'stats': ''}) 28 | for record in ipsec_records: 29 | # Format name with comment using centralized function 30 | if 'comment' in record: 31 | record['name'] = BaseOutputProcessor.format_interface_name( 32 | record['name'], 33 | record['comment'], 34 | router_entry.config_entry.interface_name_format 35 | ) 36 | 37 | return BaseDSProcessor.trimmed_records(router_entry, router_records=ipsec_records, 38 | metric_labels=metric_labels, translation_table=translation_table) 39 | except Exception as exc: 40 | print(f'Error getting IPSec active-peers info from router {router_entry.router_name}@{router_entry.config_entry.hostname}: {exc}') 41 | return None 42 | -------------------------------------------------------------------------------- /mktxp/datasource/route_ds.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | 15 | from mktxp.datasource.base_ds import BaseDSProcessor 16 | 17 | 18 | class RouteMetricsDataSource: 19 | ''' Routes Metrics data provider 20 | ''' 21 | @staticmethod 22 | def metric_records(router_entry, *, metric_labels = None, ipv6 = False): 23 | if metric_labels is None: 24 | metric_labels = [] 25 | 26 | ip_stack = 'ipv6' if ipv6 else 'ip' 27 | api_path = f'/{ip_stack}/route' 28 | 29 | try: 30 | # Get total routes 31 | total_routes = BaseDSProcessor.count_records(router_entry, api_path=api_path) 32 | if total_routes is None: 33 | # Abort if there was an error 34 | return None 35 | 36 | # Get counts per protocol 37 | routes_per_protocol = {} 38 | for label in metric_labels: 39 | count = BaseDSProcessor.count_records(router_entry, api_path=api_path, api_query={f'{label}': 'yes'}) 40 | if count is None: 41 | # Abort if there was an error 42 | return None 43 | routes_per_protocol[label] = count 44 | 45 | return { 46 | 'total_routes': total_routes, 47 | 'routes_per_protocol': routes_per_protocol 48 | } 49 | except Exception as exc: 50 | print(f'Error getting {"IPv6" if ipv6 else "IPv4"} routes info from router {router_entry.router_name}@{router_entry.config_entry.hostname}: {exc}') 51 | return None 52 | -------------------------------------------------------------------------------- /mktxp/cli/output/conn_stats_out.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | 15 | from mktxp.flow.processor.output import BaseOutputProcessor 16 | from mktxp.datasource.connection_ds import IPConnectionStatsDatasource 17 | 18 | 19 | class ConnectionsStatsOutput: 20 | ''' Connections Stats Output 21 | ''' 22 | @staticmethod 23 | def clients_summary(router_entry): 24 | connection_records = IPConnectionStatsDatasource.metric_records(router_entry, add_router_id = False) 25 | if not connection_records: 26 | print('No connection stats records') 27 | return 28 | 29 | conn_cnt = 0 30 | output_records = [] 31 | for registration_record in sorted(connection_records, key = lambda rt_record: rt_record['connection_count'], reverse=True): 32 | BaseOutputProcessor.resolve_dhcp(router_entry, registration_record, id_key = 'src_address', resolve_address = False) 33 | output_records.append(registration_record) 34 | conn_cnt += registration_record['connection_count'] 35 | 36 | output_records_cnt = 0 37 | output_entry = BaseOutputProcessor.OutputConnStatsEntry 38 | output_table = BaseOutputProcessor.output_table(output_entry) 39 | 40 | for record in output_records: 41 | output_table.add_row(output_entry(**record)) 42 | output_table.add_row(output_entry()) 43 | output_records_cnt += 1 44 | 45 | print (output_table.draw()) 46 | 47 | print(f'Distinct source addresses: {output_records_cnt}') 48 | print(f'Total open connections: {conn_cnt}', '\n') 49 | 50 | -------------------------------------------------------------------------------- /mktxp/datasource/health_ds.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | 15 | from mktxp.datasource.base_ds import BaseDSProcessor 16 | 17 | 18 | class HealthMetricsDataSource: 19 | ''' Health Metrics data provider 20 | ''' 21 | @staticmethod 22 | def metric_records(router_entry, *, metric_labels = None, translation_table = None, translate_if_no_value = False): 23 | if metric_labels is None: 24 | metric_labels = [] 25 | try: 26 | health_records = router_entry.api_connection.router_api().get_resource('/system/health').get() 27 | for record in health_records: 28 | if 'name' in record: 29 | # Note: The API in RouterOS v7.X+ returns a response like this: 30 | # [{'name': 'temperature', 'value': '33', 'type': 'C'}, ...] 31 | # To make this work for both v6 and v7 add a : pair in v7 32 | # Otherwise it is not possible to get the value by name (e.g. records['voltage']) 33 | name = record['name'] 34 | val = record.get('value', None) 35 | record[name] = val 36 | 37 | return BaseDSProcessor.trimmed_records(router_entry, router_records = health_records, metric_labels = metric_labels, 38 | translation_table = translation_table, translate_if_no_value = translate_if_no_value) 39 | except Exception as exc: 40 | print(f'Error getting system health info from router {router_entry.router_name}@{router_entry.config_entry.hostname}: {exc}') 41 | return None 42 | -------------------------------------------------------------------------------- /mktxp/datasource/package_ds.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | 15 | from mktxp.datasource.base_ds import BaseDSProcessor 16 | 17 | 18 | class PackageMetricsDataSource: 19 | ''' Wireless Metrics data provider 20 | ''' 21 | @staticmethod 22 | def metric_records(router_entry, *, metric_labels = None, add_router_id = True): 23 | if metric_labels is None: 24 | metric_labels = [] 25 | try: 26 | package_records = router_entry.api_connection.router_api().get_resource('/system/package').get() 27 | return BaseDSProcessor.trimmed_records(router_entry, router_records = package_records, metric_labels = metric_labels, add_router_id = add_router_id) 28 | except Exception as exc: 29 | print(f'Error getting installed packages info from router {router_entry.router_name}@{router_entry.config_entry.hostname}: {exc}') 30 | return None 31 | 32 | 33 | @staticmethod 34 | def is_package_installed (router_entry, package_name = None, enabled_only = True): 35 | if package_name: 36 | try: 37 | get_params = {'disabled': 'false'} if enabled_only else {} 38 | package_records = router_entry.api_connection.router_api().get_resource('/system/package').get(**get_params) 39 | return any(pkg['name'] == package_name for pkg in package_records) 40 | except Exception as exc: 41 | print(f'Error getting an installed package status from router {router_entry.router_name}@{router_entry.config_entry.hostname}: {exc}') 42 | return False 43 | -------------------------------------------------------------------------------- /mktxp/cli/config/_mktxp.conf: -------------------------------------------------------------------------------- 1 | ## Copyright (c) 2020 Arseniy Kuznetsov 2 | ## 3 | ## This program is free software; you can redistribute it and/or 4 | ## modify it under the terms of the GNU General Public License 5 | ## as published by the Free Software Foundation; either version 2 6 | ## of the License, or (at your option) any later version. 7 | ## 8 | ## This program is distributed in the hope that it will be useful, 9 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | ## GNU General Public License for more details. 12 | 13 | 14 | [MKTXP] 15 | listen = '0.0.0.0:49090' # Space separated list of socket addresses to listen to, both IPV4 and IPV6 16 | socket_timeout = 5 17 | 18 | initial_delay_on_failure = 120 19 | max_delay_on_failure = 900 20 | delay_inc_div = 5 21 | 22 | bandwidth = False # Turns metrics bandwidth metrics collection on / off 23 | bandwidth_test_dns_server = 8.8.8.8 # The DNS server to be used for the bandwidth test connectivity check 24 | bandwidth_test_interval = 600 # Interval for collecting bandwidth metrics 25 | minimal_collect_interval = 5 # Minimal metric collection interval 26 | 27 | verbose_mode = False # Set it on for troubleshooting 28 | 29 | fetch_routers_in_parallel = False # Fetch metrics from multiple routers in parallel / sequentially 30 | max_worker_threads = 5 # Max number of worker threads that can fetch routers (parallel fetch only) 31 | max_scrape_duration = 30 # Max duration of individual routers' metrics collection (parallel fetch only) 32 | total_max_scrape_duration = 90 # Max overall duration of all metrics collection (parallel fetch only) 33 | 34 | persistent_router_connection_pool = True # Use a persistent router connections pool between scrapes 35 | persistent_dhcp_cache = True # Persist DHCP cache between metric collections 36 | compact_default_conf_values = False # Compact mktxp.conf, so only specific values are kept on the individual routers' level 37 | prometheus_headers_deduplication = False # Deduplicate Prometheus HELP / TYPE headers in the metrics output 38 | -------------------------------------------------------------------------------- /mktxp/collector/connection_collector.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | 15 | from mktxp.collector.base_collector import BaseCollector 16 | from mktxp.flow.processor.output import BaseOutputProcessor 17 | from mktxp.datasource.connection_ds import IPConnectionDatasource, IPConnectionStatsDatasource 18 | 19 | 20 | class IPConnectionCollector(BaseCollector): 21 | ''' IP Connection Metrics collector 22 | ''' 23 | @staticmethod 24 | def collect(router_entry): 25 | if router_entry.config_entry.connections: 26 | connection_records = IPConnectionDatasource.metric_records(router_entry) 27 | if connection_records: 28 | connection_metrics = BaseCollector.gauge_collector('ip_connections_total', 'Number of IP connections', connection_records, 'count',) 29 | yield connection_metrics 30 | 31 | if router_entry.config_entry.connection_stats: 32 | connection_stats_records = IPConnectionStatsDatasource.metric_records(router_entry) 33 | if connection_stats_records: 34 | for connection_stat_record in connection_stats_records: 35 | BaseOutputProcessor.augment_record(router_entry, connection_stat_record, id_key = 'src_address') 36 | 37 | connection_stats_labels = ['src_address', 'dst_addresses', 'dhcp_name'] 38 | connection_stats_metrics_gauge = BaseCollector.gauge_collector('connection_stats', 'Open connection stats', 39 | connection_stats_records, 'connection_count', connection_stats_labels) 40 | yield connection_stats_metrics_gauge 41 | 42 | -------------------------------------------------------------------------------- /mktxp/collector/certificate_collector.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | from mktxp.collector.base_collector import BaseCollector 15 | from mktxp.datasource.certificate_ds import CertificateMetricsDataSource 16 | from datetime import datetime 17 | 18 | class CertificateCollector(BaseCollector): 19 | '''Certificate collector''' 20 | @staticmethod 21 | def collect(router_entry): 22 | if not router_entry.config_entry.certificate: 23 | return 24 | 25 | certificate_labels = ['name', 'digest_algorithm', 'key_type', 'country', 'state', 'locality', 'organization', 26 | 'common_name', 'key_size', 'subject_alt_name', 'days_valid', 'trusted', 'key_usage', 27 | 'ca', 'serial_number', 'key_usage', 'ca', 'serial_number', 'fingerprint', 'akid', 'skid', 28 | 'invalid_before', 'invalid_after', 'expires_after'] 29 | 30 | translation_table = { 31 | } 32 | 33 | certificate_records = CertificateMetricsDataSource.metric_records(router_entry, translation_table=translation_table, metric_labels = certificate_labels) 34 | if isinstance(certificate_records, list): 35 | for record in certificate_records: 36 | # Convert invalid_after time to epoch time 37 | record['invalid_after_epoch'] = int(datetime.strptime(record['invalid_after'], "%Y-%m-%d %H:%M:%S").timestamp()) 38 | if certificate_records: 39 | yield BaseCollector.gauge_collector('certificate_expiration_timestamp_seconds', 'The number of seconds before expiration time the certificate should renew.', certificate_records, 'invalid_after_epoch', certificate_labels) 40 | -------------------------------------------------------------------------------- /tests/flow/test_collector_handler.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | import pytest 15 | from unittest.mock import MagicMock, Mock 16 | from mktxp.flow.collector_handler import CollectorHandler 17 | 18 | @pytest.fixture 19 | def mock_router_entry(): 20 | """Fixture to create a mock RouterEntry""" 21 | entry = MagicMock() 22 | entry.is_ready.return_value = True 23 | return entry 24 | 25 | @pytest.fixture 26 | def mock_entries_handler(mock_router_entry): 27 | """Fixture to create a mock RouterEntriesHandler""" 28 | handler = MagicMock() 29 | handler.router_entries = [mock_router_entry] 30 | return handler 31 | 32 | @pytest.fixture 33 | def mock_collector_registry(): 34 | """Fixture to create a mock CollectorRegistry""" 35 | registry = MagicMock() 36 | mock_collect_func = Mock(return_value=[]) 37 | registry.registered_collectors = {'mock_collector': mock_collect_func} 38 | registry.bandwidthCollector.collect.return_value = [] 39 | return registry 40 | 41 | @pytest.mark.parametrize( 42 | "is_ready, is_done_called", 43 | [ 44 | (True, True), 45 | (False, False), 46 | ] 47 | ) 48 | def test_collect_sync_lifecycle(is_ready, is_done_called, mock_entries_handler, mock_collector_registry, mock_router_entry): 49 | # Arrange 50 | mock_router_entry.is_ready.return_value = is_ready 51 | handler = CollectorHandler(mock_entries_handler, mock_collector_registry) 52 | 53 | # Act 54 | list(handler.collect_sync()) 55 | 56 | # Assert 57 | mock_router_entry.is_ready.assert_called_once() 58 | if is_done_called: 59 | mock_router_entry.is_done.assert_called_once() 60 | else: 61 | mock_router_entry.is_done.assert_not_called() 62 | -------------------------------------------------------------------------------- /mktxp/datasource/netwatch_ds.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | 15 | from mktxp.datasource.base_ds import BaseDSProcessor 16 | from mktxp.flow.processor.output import BaseOutputProcessor 17 | 18 | 19 | class NetwatchMetricsDataSource: 20 | ''' Netwatch Metrics data provider 21 | ''' 22 | @staticmethod 23 | def metric_records(router_entry, *, metric_labels=None, translation_table=None): 24 | if metric_labels is None: 25 | metric_labels = [] 26 | try: 27 | netwatch_records = router_entry.api_connection.router_api().get_resource('/tool/netwatch').get() 28 | netwatch_records = [entry for entry in netwatch_records if entry.get('disabled', 'false') != 'true'] 29 | 30 | # since addition in ROS v7.14, name is supported natively 31 | for netwatch_record in netwatch_records: 32 | # Determine the primary identifier: use 'name' if set, fallback to 'host' 33 | name = netwatch_record.get('name') or netwatch_record.get('host') 34 | comment = netwatch_record.get('comment') 35 | 36 | # Apply the centralized formatting 37 | netwatch_record['name'] = BaseOutputProcessor.format_interface_name( 38 | name, 39 | comment, 40 | router_entry.config_entry.interface_name_format 41 | ) 42 | return BaseDSProcessor.trimmed_records(router_entry, router_records = netwatch_records, translation_table = translation_table, metric_labels = metric_labels) 43 | except Exception as exc: 44 | print(f'Error getting Netwatch info from router {router_entry.router_name}@{router_entry.config_entry.hostname}: {exc}') 45 | return None 46 | -------------------------------------------------------------------------------- /mktxp/collector/poe_collector.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | 15 | from mktxp.collector.base_collector import BaseCollector 16 | from mktxp.datasource.poe_ds import POEMetricsDataSource 17 | 18 | 19 | class POECollector(BaseCollector): 20 | ''' POE Metrics collector 21 | ''' 22 | @staticmethod 23 | def collect(router_entry): 24 | if not router_entry.config_entry.poe: 25 | return 26 | 27 | poe_labels = ['name', 'poe_out', 'poe_priority', 'poe_voltage', 'poe_out_status', 'poe_out_voltage', 'poe_out_current', 'poe_out_power'] 28 | poe_records = POEMetricsDataSource.metric_records(router_entry, metric_labels = poe_labels) 29 | 30 | if poe_records: 31 | poe_info_labels = ['name', 'poe_out', 'poe_priority', 'poe_voltage', 'poe_out_status'] 32 | poe_metrics = BaseCollector.info_collector('poe', 'POE Info Metrics', poe_records, poe_info_labels) 33 | yield poe_metrics 34 | 35 | for poe_record in poe_records: 36 | if 'poe_out_voltage' in poe_record: 37 | poe_voltage_metrics = BaseCollector.gauge_collector('poe_out_voltage', 'POE Out Voltage', [poe_record, ], 'poe_out_voltage', ['name']) 38 | yield poe_voltage_metrics 39 | 40 | if 'poe_out_current' in poe_record: 41 | poe_current_metrics = BaseCollector.gauge_collector('poe_out_current', 'POE Out Current', [poe_record, ], 'poe_out_current', ['name']) 42 | yield poe_current_metrics 43 | 44 | if 'poe_out_power' in poe_record: 45 | poe_power_metrics = BaseCollector.gauge_collector('poe_out_power', 'POE Out Power', [poe_record, ], 'poe_out_power', ['name']) 46 | yield poe_power_metrics 47 | 48 | -------------------------------------------------------------------------------- /mktxp/datasource/pool_ds.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | 15 | from mktxp.datasource.base_ds import BaseDSProcessor 16 | 17 | 18 | class PoolMetricsDataSource: 19 | ''' Pool Metrics data provider 20 | ''' 21 | @staticmethod 22 | def metric_records(router_entry, *, metric_labels = None, ipv6 = False): 23 | ip_stack = 'ipv6' if ipv6 else 'ip' 24 | if metric_labels is None: 25 | metric_labels = [] 26 | try: 27 | pool_records = router_entry.api_connection.router_api().get_resource(f'/{ip_stack}/pool').get() 28 | return BaseDSProcessor.trimmed_records(router_entry, router_records = pool_records, metric_labels = metric_labels) 29 | except Exception as exc: 30 | print(f'Error getting {"IPv6" if ipv6 else "IPv4"} pool info from router {router_entry.router_name}@{router_entry.config_entry.hostname}: {exc}') 31 | return None 32 | 33 | 34 | class PoolUsedMetricsDataSource: 35 | ''' Pool/Used Metrics data provider 36 | ''' 37 | @staticmethod 38 | def metric_records(router_entry, *, metric_labels = None, ipv6 = False): 39 | ip_stack = 'ipv6' if ipv6 else 'ip' 40 | if metric_labels is None: 41 | metric_labels = [] 42 | try: 43 | pool_used_records = router_entry.api_connection.router_api().get_resource(f'/{ip_stack}/pool/used').get() 44 | return BaseDSProcessor.trimmed_records(router_entry, router_records = pool_used_records, metric_labels = metric_labels) 45 | except Exception as exc: 46 | print(f'Error getting {"IPv6" if ipv6 else "IPv4"} pool used info from router {router_entry.router_name}@{router_entry.config_entry.hostname}: {exc}') 47 | return None -------------------------------------------------------------------------------- /mktxp/datasource/queue_ds.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | 15 | from mktxp.datasource.base_ds import BaseDSProcessor 16 | 17 | 18 | class QueueMetricsDataSource: 19 | ''' Queue Metrics data provider 20 | ''' 21 | @staticmethod 22 | def metric_records(router_entry, *, metric_labels = None, kind = 'tree'): 23 | if metric_labels is None: 24 | metric_labels = [] 25 | try: 26 | queue_records = router_entry.api_connection.router_api().get_resource(f'/queue/{kind}/').get() 27 | queue_records = BaseDSProcessor.trimmed_records(router_entry, router_records = queue_records, metric_labels = metric_labels) 28 | except Exception as exc: 29 | print(f'Error getting system resource info from router {router_entry.router_name}@{router_entry.config_entry.hostname}: {exc}') 30 | return None 31 | 32 | if kind == 'tree': 33 | return queue_records 34 | 35 | # simple queue records need splitting upload/download values 36 | splitted_queue_records = [] 37 | for queue_record in queue_records: 38 | splitted_queue_record = {} 39 | for key, value in queue_record.items(): 40 | if isinstance(value, str): 41 | split_values = value.split('/') 42 | else: 43 | split_values = [value] 44 | if split_values and len(split_values) > 1: 45 | splitted_queue_record[f'{key}_up'] = split_values[0] 46 | splitted_queue_record[f'{key}_down'] = split_values[1] 47 | else: 48 | splitted_queue_record[key] = value 49 | splitted_queue_records.append(splitted_queue_record) 50 | return splitted_queue_records 51 | 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /.github/workflows/build-and-push.yaml: -------------------------------------------------------------------------------- 1 | name: Docker Build 2 | 3 | on: 4 | push: 5 | branches: ['**'] 6 | tags: ['**'] 7 | paths-ignore: 8 | - 'README.md' 9 | - 'LICENSE' 10 | pull_request: 11 | types: [opened, reopened] 12 | branches: [main] 13 | paths-ignore: 14 | - 'README.md' 15 | - 'LICENSE' 16 | release: 17 | types: [published] 18 | 19 | jobs: 20 | call-docker-build: 21 | name: Call Docker Build 22 | runs-on: ubuntu-latest 23 | permissions: 24 | contents: read 25 | packages: write 26 | pull-requests: write 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v4 30 | 31 | - name: Login to GitHub Container Registry 32 | uses: docker/login-action@v3 33 | with: 34 | registry: ghcr.io 35 | username: ${{ github.repository_owner }} 36 | password: ${{ secrets.GITHUB_TOKEN }} 37 | 38 | - name: Collect Docker metadata 39 | id: meta 40 | uses: docker/metadata-action@v5 41 | with: 42 | flavor: latest=false 43 | images: ghcr.io/${{ github.repository }} 44 | tags: | 45 | type=raw,value=latest,enable=${{ github.event_name == 'release' }} 46 | type=semver,pattern={{major}}.{{minor}}.{{patch}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} 47 | type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} 48 | type=semver,pattern={{major}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} 49 | type=ref,event=branch 50 | 51 | - name: Log Docker metadata 52 | run: echo "${{ steps.meta.outputs.tags }}" 53 | 54 | - name: Set up QEMU 55 | uses: docker/setup-qemu-action@v3 56 | with: 57 | platforms: amd64,arm64 58 | 59 | - name: Set up Docker Buildx 60 | uses: docker/setup-buildx-action@v3 61 | 62 | - name: Build and publish Docker image 63 | uses: docker/build-push-action@v6 64 | with: 65 | context: . 66 | push: ${{ github.ref == 'refs/heads/main' || github.event_name == 'release' || startsWith(github.ref, 'refs/tags/') }} 67 | platforms: | 68 | linux/amd64 69 | linux/arm64/v8 70 | tags: ${{ steps.meta.outputs.tags }} 71 | labels: ${{ steps.meta.outputs.labels }} 72 | cache-to: type=gha,mode=min 73 | cache-from: type=gha 74 | -------------------------------------------------------------------------------- /mktxp/datasource/system_resource_ds.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | 15 | from mktxp.datasource.base_ds import BaseDSProcessor 16 | from mktxp.utils.utils import builtin_wifi_capsman_version 17 | 18 | 19 | class SystemResourceMetricsDataSource: 20 | ''' System Resource Metrics data provider 21 | ''' 22 | @staticmethod 23 | def metric_records(router_entry, *, metric_labels = None, translation_table=None): 24 | if metric_labels is None: 25 | metric_labels = [] 26 | try: 27 | system_resource_records = router_entry.api_connection.router_api().get_resource('/system/resource').get() 28 | return BaseDSProcessor.trimmed_records(router_entry, router_records = system_resource_records, metric_labels = metric_labels, translation_table=translation_table) 29 | except Exception as exc: 30 | print(f'Error getting system resource info from router {router_entry.router_name}@{router_entry.config_entry.hostname}: {exc}') 31 | return None 32 | 33 | @staticmethod 34 | def os_version(router_entry): 35 | try: 36 | system_version_records = router_entry.api_connection.router_api().get_resource('/system/resource').call('print', {'proplist':'version'}) 37 | for record in system_version_records: 38 | ver = record.get('version', None) 39 | if ver: 40 | return ver 41 | except Exception as exc: 42 | print(f'Error getting OS version info from router {router_entry.router_name}@{router_entry.config_entry.hostname}: {exc}') 43 | return None 44 | 45 | @staticmethod 46 | def has_builtin_wifi_capsman(router_entry): 47 | ver = SystemResourceMetricsDataSource.os_version(router_entry) 48 | if ver: 49 | return builtin_wifi_capsman_version(ver) 50 | return False 51 | -------------------------------------------------------------------------------- /tests/flow/test_router_connection.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | import pytest 15 | import routeros_api.api_structure 16 | # monkey patch via importing the module 17 | import mktxp.flow.router_connection 18 | 19 | @pytest.mark.parametrize("input_bytes, expected_string", [ 20 | (b'But are these differences really that small?', 'But are these differences really that small?'), 21 | (b'\xe4\xbd\x86\xe6\x98\xaf\xef\xbc\x8c\xe8\xbf\x99\xe4\xba\x9b\xe5\xb7\xae\xe5\xbc\x82\xe7\x9c\x9f\xe7\x9a\x84\xe9\x82\xa3\xe4\xb9\x88\xe5\xb0\x8f\xe5\x90\x97\xef\xbc\x9f', '但是,这些差异真的那么小吗?'), 22 | (b'Mas ser\xc3\xa3o estas diferen\xc3\xa7as assim t\xc3\xa3o pequenas?', 'Mas serão estas diferenças assim tão pequenas?'), 23 | (b'\xd0\x9d\xd0\xbe \xd0\xb4\xd0\xb5\xd0\xb9\xd1\x81\xd1\x82\xd0\xb2\xd0\xb8\xd1\x82\xd0\xb5\xd0\xbb\xd1\x8c\xd0\xbd\xd0\xbe \xd0\xbb\xd0\xb8 \xd1\x8d\xd1\x82\xd0\xb8 \xd1\x80\xd0\xb0\xd0\xb7\xd0\xbb\xd0\xb8\xd1\x87\xd0\xb8\xd1\x8f \xd1\x82\xd0\xb0\xd0\xba \xd0\xbc\xd0\xb0\xd0\xbb\xd1\x8b?', 'Но действительно ли эти различия так малы?'), 24 | (b'\xd0\x90\xd0\xbb\xd0\xb5 \xd1\x87\xd0\xb8 \xd1\x81\xd0\xbf\xd1\x80\xd0\xb0\xd0\xb2\xd0\xb4\xd1\x96 \xd1\x86\xd1\x96 \xd0\xb2\xd1\x96\xd0\xb4\xd0\xbc\xd1\x96\xd0\xbd\xd0\xbd\xd0\xbe\xd1\x81\xd1\x82\xd1\x96 \xd1\x82\xd0\xb0\xd0\xba\xd1\x96 \xd0\xbd\xd0\xb5\xd0\xb7\xd0\xbd\xd0\xb0\xd1\x87\xd0\xbd\xd1\x96?', 'Але чи справді ці відмінності такі незначні?'), 25 | (b'Jsou v\xc5\xa1ak tyto rozd\xc3\xadly opravdu tak mal\xc3\xa9?', 'Jsou však tyto rozdíly opravdu tak malé?'), 26 | (b'\xa1\xa3', '¡£'), # Some latin-1 characters 27 | (b'J\xf6rgensen', 'Jörgensen'), 28 | (b'fran\xe7aise', 'française'), 29 | (b'espa\xf1ol', 'español'), 30 | (b'sch\xf6n', 'schön'), 31 | (b'mixed content \xe4\xbd\xa0\xe5\xa5\xbd', 'mixed content 你好'), 32 | ]) 33 | def test_decode_bytes_monkey_patch(input_bytes, expected_string): 34 | field = routeros_api.api_structure.StringField() 35 | assert field.get_python_value(input_bytes) == expected_string 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # PyCharm IDE 132 | .idea/ 133 | 134 | # Ignore compressed archives 135 | *.tgz 136 | *.tar.gz 137 | *.tar 138 | *.zip 139 | *.gz 140 | *.rar -------------------------------------------------------------------------------- /mktxp/datasource/wireless_ds.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | from mktxp.datasource.base_ds import BaseDSProcessor 15 | from mktxp.datasource.package_ds import PackageMetricsDataSource 16 | from mktxp.flow.router_entry import RouterEntryWirelessType 17 | 18 | class WirelessMetricsDataSource: 19 | ''' Wireless Metrics data provider 20 | ''' 21 | WIRELESS = 'wireless' 22 | WIFIWAVE2 = 'wifiwave2' 23 | WIFI = 'wifi' 24 | 25 | @staticmethod 26 | def metric_records(router_entry, *, metric_labels = None, add_router_id = True): 27 | if metric_labels is None: 28 | metric_labels = [] 29 | try: 30 | wireless_package = WirelessMetricsDataSource.wireless_package(router_entry) 31 | registration_table_records = router_entry.api_connection.router_api().get_resource(f'/interface/{wireless_package}/registration-table').get() 32 | 33 | # With wifiwave2, Mikrotik renamed the field 'signal-strength' to 'signal' 34 | # For backward compatibility, including both variants 35 | for record in registration_table_records: 36 | if 'signal' in record: 37 | record['signal-strength'] = record['signal'] 38 | 39 | return BaseDSProcessor.trimmed_records(router_entry, router_records = registration_table_records, metric_labels = metric_labels, add_router_id = add_router_id,) 40 | except Exception as exc: 41 | print(f'Error getting wireless registration table info from router {router_entry.router_name}@{router_entry.config_entry.hostname}: {exc}') 42 | return None 43 | 44 | @staticmethod 45 | def wireless_package(router_entry): 46 | if router_entry.wireless_type in (RouterEntryWirelessType.DUAL, RouterEntryWirelessType.WIRELESS): 47 | return WirelessMetricsDataSource.WIRELESS 48 | elif router_entry.wireless_type == RouterEntryWirelessType.WIFIWAVE2: 49 | return WirelessMetricsDataSource.WIFIWAVE2 50 | else: 51 | return WirelessMetricsDataSource.WIFI 52 | 53 | 54 | -------------------------------------------------------------------------------- /mktxp/datasource/routerboard_ds.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | 15 | from mktxp.datasource.base_ds import BaseDSProcessor 16 | 17 | 18 | class RouterboardMetricsDataSource: 19 | ''' Routerboard Metrics data provider 20 | ''' 21 | @staticmethod 22 | def metric_records(router_entry, *, metric_labels = None): 23 | if metric_labels is None: 24 | metric_labels = [] 25 | try: 26 | routerboard_records = router_entry.api_connection.router_api().get_resource('/system/routerboard').get() 27 | return BaseDSProcessor.trimmed_records(router_entry, router_records = routerboard_records, metric_labels = metric_labels) 28 | except Exception as exc: 29 | print(f'Error getting system routerboard info from router {router_entry.router_name}@{router_entry.config_entry.hostname}: {exc}') 30 | return None 31 | 32 | @staticmethod 33 | def firmware_version(router_entry): 34 | try: 35 | version_st = router_entry.api_connection.router_api().get_resource('/system/routerboard').call('print', {'proplist':'current-firmware'})[0] 36 | if version_st.get('current-firmware'): 37 | return version_st['current-firmware'] 38 | return None 39 | except Exception as exc: 40 | print(f'Error getting routerboard current-firmware from router {router_entry.router_name}@{router_entry.config_entry.hostname}: {exc}') 41 | return None 42 | 43 | @staticmethod 44 | def firmware_upgrade_version(router_entry): 45 | try: 46 | version_st = router_entry.api_connection.router_api().get_resource('/system/routerboard').call('print', {'proplist':'upgrade-firmware'})[0] 47 | if version_st.get('upgrade-firmware'): 48 | return version_st['upgrade-firmware'] 49 | return None 50 | except Exception as exc: 51 | print(f'Error getting routerboard upgrade-firmware from router {router_entry.router_name}@{router_entry.config_entry.hostname}: {exc}') 52 | return None 53 | 54 | -------------------------------------------------------------------------------- /mktxp/cli/output/dhcp_out.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | 15 | from mktxp.flow.processor.output import BaseOutputProcessor 16 | from mktxp.datasource.dhcp_ds import DHCPMetricsDataSource 17 | 18 | 19 | class DHCPOutput: 20 | ''' DHCP Clients CLI Output 21 | ''' 22 | @staticmethod 23 | def clients_summary(router_entry): 24 | dhcp_lease_labels = ['host_name', 'comment', 'active_address', 'address', 'mac_address', 'server', 'expires_after'] 25 | dhcp_lease_records = DHCPMetricsDataSource.metric_records(router_entry, metric_labels = dhcp_lease_labels, add_router_id = False, translate = False, dhcp_cache = False) 26 | if not dhcp_lease_records: 27 | print('No DHCP registration records') 28 | return 29 | 30 | dhcp_by_server = {} 31 | for dhcp_lease_record in sorted(dhcp_lease_records, key = lambda dhcp_record: dhcp_record['address'], reverse=True): 32 | server = dhcp_lease_record.get('server', 'all') 33 | if server == 'all': 34 | dhcp_lease_record['server'] = server 35 | if server in dhcp_by_server.keys(): 36 | dhcp_by_server[server].append(dhcp_lease_record) 37 | else: 38 | dhcp_by_server[server] = [dhcp_lease_record] 39 | 40 | output_records = 0 41 | lease_records = len(dhcp_lease_records) 42 | output_entry = BaseOutputProcessor.OutputDHCPEntry 43 | output_table = BaseOutputProcessor.output_table(output_entry) 44 | 45 | for key in dhcp_by_server.keys(): 46 | for record in dhcp_by_server[key]: 47 | record['host_name'] = BaseOutputProcessor.dhcp_name(router_entry, record, drop_comment = True) 48 | output_table.add_row(output_entry(**record)) 49 | output_records += 1 50 | if output_records < lease_records: 51 | output_table.add_row(output_entry()) 52 | 53 | print (output_table.draw()) 54 | 55 | for server in dhcp_by_server.keys(): 56 | print(f'{server} clients: {len(dhcp_by_server[server])}') 57 | print(f'Total DHCP clients: {output_records}', '\n') 58 | -------------------------------------------------------------------------------- /mktxp/cli/output/capsman_out.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | 15 | from mktxp.flow.processor.output import BaseOutputProcessor 16 | from mktxp.datasource.capsman_ds import CapsmanRegistrationsMetricsDataSource 17 | 18 | class CapsmanOutput: 19 | ''' CAPsMAN CLI Output 20 | ''' 21 | @staticmethod 22 | def clients_summary(router_entry): 23 | registration_labels = ['interface', 'ssid', 'mac_address', 'rx_signal', 'uptime', 'tx_rate', 'rx_rate'] 24 | registration_records = CapsmanRegistrationsMetricsDataSource.metric_records(router_entry, metric_labels = registration_labels, add_router_id = False) 25 | if not registration_records: 26 | print('No CAPsMAN registration records') 27 | return 28 | 29 | # translate / trim / augment registration records 30 | dhcp_rt_by_interface = {} 31 | for registration_record in sorted(registration_records, key = lambda rt_record: rt_record['rx_signal'], reverse=True): 32 | BaseOutputProcessor.augment_record(router_entry, registration_record) 33 | 34 | interface = registration_record['interface'] 35 | if interface in dhcp_rt_by_interface.keys(): 36 | dhcp_rt_by_interface[interface].append(registration_record) 37 | else: 38 | dhcp_rt_by_interface[interface] = [registration_record] 39 | 40 | output_records = 0 41 | registration_records = len(registration_records) 42 | output_entry = BaseOutputProcessor.OutputCapsmanEntry 43 | output_table = BaseOutputProcessor.output_table(output_entry) 44 | 45 | for key in dhcp_rt_by_interface.keys(): 46 | for record in dhcp_rt_by_interface[key]: 47 | output_table.add_row(output_entry(**record)) 48 | output_records += 1 49 | if output_records < registration_records: 50 | output_table.add_row(output_entry()) 51 | 52 | print (output_table.draw()) 53 | 54 | for server in dhcp_rt_by_interface.keys(): 55 | print(f'{server} clients: {len(dhcp_rt_by_interface[server])}') 56 | print(f'Total connected CAPsMAN clients: {output_records}', '\n') 57 | 58 | -------------------------------------------------------------------------------- /mktxp/datasource/dhcp_ds.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | 15 | from mktxp.datasource.base_ds import BaseDSProcessor 16 | from mktxp.utils.utils import parse_mkt_uptime 17 | 18 | 19 | class DHCPMetricsDataSource: 20 | ''' DHCP Metrics data provider 21 | ''' 22 | @staticmethod 23 | def metric_records(router_entry, *, metric_labels = None, add_router_id = True, dhcp_cache = True, translate = True, bound = False): 24 | if metric_labels is None or dhcp_cache: 25 | metric_labels = ['host_name', 'comment', 'active_address', 'address', 'mac_address', 'server', 'expires_after', 'client_id', 'active_mac_address'] 26 | 27 | if dhcp_cache and router_entry.dhcp_records: 28 | return router_entry.dhcp_records 29 | try: 30 | if bound: 31 | dhcp_lease_records = router_entry.dhcp_entry.api_connection.router_api().get_resource('/ip/dhcp-server/lease').get(status='bound') 32 | else: 33 | dhcp_lease_records = router_entry.dhcp_entry.api_connection.router_api().get_resource('/ip/dhcp-server/lease').call('print', {'active':''}) 34 | 35 | # translation rules 36 | translation_table = {} 37 | if 'comment' in metric_labels: 38 | translation_table['comment'] = lambda c: c if c else '' 39 | if 'host_name' in metric_labels: 40 | translation_table['host_name'] = lambda c: c if c else '' 41 | if 'expires_after' in metric_labels and translate: 42 | translation_table['expires_after'] = lambda c: parse_mkt_uptime(c) if c else 0 43 | if 'active_address' in metric_labels: 44 | translation_table['active_address'] = lambda c: c if c else '' 45 | 46 | records = BaseDSProcessor.trimmed_records(router_entry, router_records = dhcp_lease_records, metric_labels = metric_labels, add_router_id = add_router_id, translation_table = translation_table) 47 | if dhcp_cache: 48 | router_entry.dhcp_records = records 49 | return records 50 | 51 | except Exception as exc: 52 | print(f'Error getting dhcp info from router {router_entry.dhcp_entry.router_name}@{router_entry.dhcp_entry.config_entry.hostname}: {exc}') 53 | return None 54 | -------------------------------------------------------------------------------- /mktxp/collector/pool_collector.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | 15 | from mktxp.cli.config.config import MKTXPConfigKeys 16 | from mktxp.collector.base_collector import BaseCollector 17 | from mktxp.datasource.pool_ds import PoolMetricsDataSource, PoolUsedMetricsDataSource 18 | 19 | 20 | class PoolCollector(BaseCollector): 21 | ''' IP Pool Metrics collector 22 | ''' 23 | @staticmethod 24 | def collect(router_entry): 25 | # ~*~*~*~*~*~ IPv4 ~*~*~*~*~*~ 26 | if router_entry.config_entry.pool: 27 | yield from PoolCollector._process_ip_stack(router_entry) 28 | 29 | # ~*~*~*~*~*~ IPv6 ~*~*~*~*~*~ 30 | if router_entry.config_entry.ipv6_pool: 31 | yield from PoolCollector._process_ip_stack(router_entry, ipv6=True) 32 | 33 | # helpers 34 | @staticmethod 35 | def _process_ip_stack(router_entry, ipv6=False): 36 | ip_stack = 'ipv6' if ipv6 else 'ipv4' 37 | 38 | # initialize all pool counts, including those currently not used 39 | pool_records = PoolMetricsDataSource.metric_records(router_entry, metric_labels = ['name'], ipv6=ipv6) 40 | if pool_records: 41 | pool_used_labels = ['pool'] 42 | pool_used_counts = {pool_record['name']: 0 for pool_record in pool_records} 43 | 44 | # for pools in usage, calculate the current numbers 45 | pool_used_records = PoolUsedMetricsDataSource.metric_records(router_entry, metric_labels = pool_used_labels, ipv6=ipv6) 46 | for pool_used_record in pool_used_records: 47 | pool_used_counts[pool_used_record['pool']] = pool_used_counts.get(pool_used_record['pool'], 0) + 1 48 | 49 | # compile used-per-pool records 50 | used_per_pool_records = [{ MKTXPConfigKeys.ROUTERBOARD_NAME: router_entry.router_id[MKTXPConfigKeys.ROUTERBOARD_NAME], 51 | MKTXPConfigKeys.ROUTERBOARD_ADDRESS: router_entry.router_id[MKTXPConfigKeys.ROUTERBOARD_ADDRESS], 52 | 'pool': key, 'count': value} for key, value in pool_used_counts.items()] 53 | 54 | # yield used-per-pool metrics 55 | used_per_pool_metrics = BaseCollector.gauge_collector(f'ip_pool_used{"_ipv6" if ipv6 else ""}', f'Number of used addresses per IP pool ({ip_stack.upper()})', used_per_pool_records, 'count', ['pool']) 56 | yield used_per_pool_metrics 57 | -------------------------------------------------------------------------------- /tests/flow/test_base_proc.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | import gzip 15 | import pytest 16 | from mktxp.flow.processor.base_proc import PrometheusHeadersDeduplicatingMiddleware 17 | 18 | # A mock WSGI app to simulate the prometheus_client 19 | def mock_app(environ, start_response): 20 | status = '200 OK' 21 | body = b'# HELP metric1 doc1\n# TYPE metric1 gauge\nmetric1{label="a"} 1\n# HELP metric1 doc1\n# TYPE metric1 gauge\nmetric1{label="b"} 2\n' 22 | headers = [('Content-Type', 'text/plain; version=0.0.4; charset=utf-8')] 23 | 24 | accept_encoding = environ.get('HTTP_ACCEPT_ENCODING', '') 25 | if 'gzip' in accept_encoding: 26 | headers.append(('Content-Encoding', 'gzip')) 27 | body = gzip.compress(body) 28 | 29 | headers.append(('Content-Length', str(len(body)))) 30 | start_response(status, headers) 31 | return [body] 32 | 33 | @pytest.mark.parametrize( 34 | "case_name, accept_encoding, expected_gzipped", 35 | [ 36 | ("plain_text", "", False), 37 | ("gzipped", "gzip, deflate", True), 38 | ] 39 | ) 40 | def test_deduplicating_middleware(case_name, accept_encoding, expected_gzipped): 41 | """ 42 | Tests the Prometheus client middleware to de-duplicates HELP/TYPE headers 43 | Parameterized to run for both plain text and gzipped responses 44 | """ 45 | middleware = PrometheusHeadersDeduplicatingMiddleware(mock_app) 46 | 47 | captured_status = [] 48 | captured_headers = [] 49 | def mock_start_response(status, headers): 50 | captured_status.append(status) 51 | captured_headers.append(headers) 52 | 53 | environ = {'HTTP_ACCEPT_ENCODING': accept_encoding} 54 | 55 | response_iter = middleware(environ, mock_start_response) 56 | response_body = b''.join(response_iter) 57 | 58 | assert captured_status[0] == '200 OK' 59 | 60 | expected_text = '# HELP metric1 doc1\n# TYPE metric1 gauge\nmetric1{label="a"} 1\nmetric1{label="b"} 2\n' 61 | 62 | headers_dict = dict(captured_headers[0]) 63 | 64 | if expected_gzipped: 65 | assert gzip.decompress(response_body).decode('utf-8') == expected_text 66 | assert headers_dict['Content-Length'] == str(len(response_body)) 67 | assert headers_dict['Content-Encoding'] == 'gzip' 68 | else: 69 | assert response_body.decode('utf-8') == expected_text 70 | assert headers_dict['Content-Length'] == str(len(expected_text.encode('utf-8'))) 71 | assert 'Content-Encoding' not in headers_dict 72 | -------------------------------------------------------------------------------- /tests/collector/test_connection_collector.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | # Copyright (c) 2020 Arseniy Kuznetsov 3 | # 4 | # This program is free software; you can redistribute it and/or 5 | # modify it under the terms of the GNU General Public License 6 | # as published by the Free Software Foundation; either version 2 7 | # of the License, or (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | 14 | import pytest 15 | from unittest.mock import MagicMock 16 | from mktxp.datasource.connection_ds import IPConnectionStatsDatasource 17 | from mktxp.flow.router_entry import RouterEntry 18 | 19 | @pytest.mark.parametrize("connection_count_str, should_make_stats_call", [ 20 | ('0', False), # Scenario with zero connections 21 | ('123', True), # Scenario with non-zero connections 22 | ]) 23 | def test_ip_connection_stats_datasource_checks_count_first(connection_count_str, should_make_stats_call): 24 | """ 25 | Verifies that IPConnectionStatsDatasource checks the connection count and avoids fetching the full stats list if the count is 0 26 | """ 27 | # Mocking the router_entry and its components 28 | mock_router_entry = MagicMock(spec=RouterEntry) 29 | mock_router_entry.router_name = "TestRouter" 30 | mock_router_entry.config_entry = MagicMock() 31 | mock_router_entry.config_entry.hostname = "testhost" 32 | mock_router_entry.api_connection = MagicMock() 33 | mock_router_entry.router_id = {'routerboard_name': 'test_router'} 34 | 35 | # Mock the API call & responces 36 | mock_api = MagicMock() 37 | mock_router_entry.api_connection.router_api.return_value = mock_api 38 | call_mock = mock_api.get_resource.return_value.call 39 | count_response = MagicMock() 40 | count_response.done_message = {'ret': connection_count_str} 41 | 42 | stats_response = [{'src-address': '1.1.1.1:123', 'dst-address': '2.2.2.2:80', 'protocol': 'tcp'}] 43 | 44 | # Side effect function to route calls based on arguments 45 | def api_call_router(*args, **kwargs): 46 | params = args[1] 47 | if params.get('count-only') == '': 48 | return count_response 49 | elif params.get('proplist') == 'src-address,dst-address,protocol': 50 | return stats_response 51 | return MagicMock() 52 | 53 | call_mock.side_effect = api_call_router 54 | 55 | # Test the method of focus 56 | result = IPConnectionStatsDatasource.metric_records(mock_router_entry) 57 | if should_make_stats_call: 58 | # This one should have been called twice, once for count and once for the stats 59 | assert call_mock.call_count == 2 60 | assert result is not None 61 | assert len(result) > 0 62 | assert result[0]['src_address'] == '1.1.1.1' 63 | else: 64 | # And this just once for the count 65 | call_mock.assert_called_once() 66 | assert result == [] 67 | -------------------------------------------------------------------------------- /mktxp/cli/output/wifi_out.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | 15 | from mktxp.flow.processor.output import BaseOutputProcessor 16 | from mktxp.datasource.wireless_ds import WirelessMetricsDataSource 17 | from mktxp.flow.router_entry import RouterEntryWirelessType 18 | 19 | class WirelessOutput: 20 | ''' Wireless Clients CLI Output 21 | ''' 22 | @staticmethod 23 | def clients_summary(router_entry): 24 | registration_labels = ['interface', 'mac_address', 'signal_strength', 'uptime', 'tx_rate', 'rx_rate', 'signal_to_noise'] 25 | registration_records = WirelessMetricsDataSource.metric_records(router_entry, metric_labels = registration_labels, add_router_id = False) 26 | if not registration_records: 27 | print('No wireless registration records') 28 | return 29 | 30 | # translate / trim / augment registration records 31 | dhcp_rt_by_interface = {} 32 | 33 | key = lambda rt_record: rt_record['signal_strength'] if rt_record.get('signal_strength') else rt_record['interface'] 34 | for registration_record in sorted(registration_records, key = key, reverse=True): 35 | BaseOutputProcessor.augment_record(router_entry, registration_record) 36 | 37 | interface = registration_record['interface'] 38 | if interface in dhcp_rt_by_interface.keys(): 39 | dhcp_rt_by_interface[interface].append(registration_record) 40 | else: 41 | dhcp_rt_by_interface[interface] = [registration_record] 42 | 43 | output_records = 0 44 | registration_records = len(registration_records) 45 | output_entry = BaseOutputProcessor.OutputWirelessEntry \ 46 | if router_entry.wireless_type in (RouterEntryWirelessType.DUAL, RouterEntryWirelessType.WIRELESS) else BaseOutputProcessor.OutputWiFiEntry 47 | output_table = BaseOutputProcessor.output_table(output_entry) 48 | 49 | for key in dhcp_rt_by_interface.keys(): 50 | for record in dhcp_rt_by_interface[key]: 51 | output_table.add_row(output_entry(**record)) 52 | output_records += 1 53 | if output_records < registration_records: 54 | output_table.add_row(output_entry()) 55 | 56 | print (output_table.draw()) 57 | 58 | for server in dhcp_rt_by_interface.keys(): 59 | print(f'{server} clients: {len(dhcp_rt_by_interface[server])}') 60 | print(f'Total connected WiFi devices: {output_records}', '\n') 61 | 62 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | ## Copyright (c) 2020 Arseniy Kuznetsov 2 | ## 3 | ## This program is free software; you can redistribute it and/or 4 | ## modify it under the terms of the GNU General Public License 5 | ## as published by the Free Software Foundation; either version 2 6 | ## of the License, or (at your option) any later version. 7 | ## 8 | ## This program is distributed in the hope that it will be useful, 9 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | ## GNU General Public License for more details. 12 | 13 | from setuptools import setup, find_packages 14 | from os import path 15 | 16 | # read the README.md contents 17 | pkg_dir = path.abspath(path.dirname(__file__)) 18 | with open(path.join(pkg_dir, 'README.md'), encoding='utf-8') as f: 19 | long_description = f.read() 20 | 21 | setup( 22 | name='mktxp', 23 | version='1.2.16', 24 | 25 | url='https://github.com/akpw/mktxp', 26 | 27 | author='Arseniy Kuznetsov', 28 | author_email='k.arseniy@gmail.com', 29 | 30 | long_description=long_description, 31 | long_description_content_type='text/markdown', 32 | 33 | description=(''' 34 | Prometheus Exporter for Mikrotik RouterOS devices 35 | '''), 36 | license='GNU General Public License v2 (GPLv2)', 37 | 38 | packages=find_packages(exclude=['test*']), 39 | 40 | package_data = { 41 | '': ['config/*.conf'], 42 | }, 43 | 44 | keywords = 'Mikrotik RouterOS Prometheus Exporter', 45 | 46 | install_requires = ['prometheus-client>=0.9.0', 47 | 'RouterOS-api>=0.19.0', 48 | 'configobj>=5.0.6', 49 | 'humanize>=3.2.0', 50 | 'texttable>=1.6.3', 51 | 'speedtest-cli>=2.1.2', 52 | 'waitress>=3.0.0', 53 | 'packaging>=24.0', 54 | 'pyyaml>=5.1' 55 | ], 56 | 57 | test_suite = 'tests', 58 | 59 | extras_require={ 60 | 'test': [ 61 | 'pytest', 62 | 'pytest-mock', 63 | ], 64 | }, 65 | 66 | entry_points={'console_scripts': [ 67 | 'mktxp = mktxp.cli.dispatch:main', 68 | ]}, 69 | 70 | zip_safe=True, 71 | 72 | classifiers=[ 73 | 'Development Status :: 4 - Beta', 74 | 'License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)', 75 | 'Programming Language :: Python', 76 | 'Programming Language :: Python :: 3.8', 77 | 'Programming Language :: Python :: 3 :: Only', 78 | 'Intended Audience :: Developers', 79 | 'Intended Audience :: System Administrators', 80 | 'Intended Audience :: Information Technology', 81 | 'Intended Audience :: Customer Service', 82 | 'Operating System :: MacOS', 83 | 'Operating System :: POSIX :: BSD :: FreeBSD', 84 | 'Operating System :: POSIX :: Linux', 85 | 'Topic :: System', 86 | 'Topic :: System :: Systems Administration', 87 | 'Topic :: Utilities' 88 | ] 89 | ) 90 | 91 | 92 | 93 | -------------------------------------------------------------------------------- /tests/collector/test_user_collector.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | import pytest 15 | from unittest.mock import Mock, patch 16 | from mktxp.collector.user_collector import UserCollector 17 | 18 | # MikroTik when format: YYYY-MM-DD HH:MM:SS 19 | # Case 1: Records with duplicates 20 | records_with_duplicates = [ 21 | {'name': 'user1', 'when': '2024-12-18 10:54:02', 'address': 'a1', 'via': 'v1', 'group': 'g1'}, 22 | {'name': 'user2', 'when': '2024-12-18 10:55:02', 'address': 'a2', 'via': 'v2', 'group': 'g2'}, 23 | {'name': 'user1', 'when': '2024-12-18 10:54:02', 'address': 'a1', 'via': 'v1', 'group': 'g1'}, # Duplicate 24 | ] 25 | expected_names_1 = {'user1', 'user2'} 26 | 27 | # Case 2: No duplicates 28 | records_without_duplicates = [ 29 | {'name': 'user1', 'when': '2024-12-18 10:54:02', 'address': 'a1', 'via': 'v1', 'group': 'g1'}, 30 | {'name': 'user2', 'when': '2024-12-18 10:55:02', 'address': 'a2', 'via': 'v2', 'group': 'g2'}, 31 | ] 32 | expected_names_2 = {'user1', 'user2'} 33 | 34 | # Case 3: All duplicates 35 | records_all_duplicates = [ 36 | {'name': 'user1', 'when': '2024-12-18 10:54:02', 'address': 'a1', 'via': 'v1', 'group': 'g1'}, 37 | {'name': 'user1', 'when': '2024-12-18 10:54:02', 'address': 'a1', 'via': 'v1', 'group': 'g1'}, 38 | ] 39 | expected_names_3 = {'user1'} 40 | 41 | 42 | @pytest.mark.parametrize("input_records, expected_sample_count, expected_names", [ 43 | (records_with_duplicates, 2, expected_names_1), 44 | (records_without_duplicates, 2, expected_names_2), 45 | (records_all_duplicates, 1, expected_names_3), 46 | ]) 47 | @patch('mktxp.datasource.user_ds.UserMetricsDataSource.metric_records') 48 | def test_user_collector_deduplicates_records(mock_metric_records, input_records, expected_sample_count, expected_names): 49 | """ 50 | Tests that the UserCollector correctly de-duplicates records 51 | from the data source before creating metrics. 52 | """ 53 | # 1. Setup mock data and objects 54 | mock_router_entry = Mock() 55 | mock_router_entry.config_entry.user = True 56 | mock_metric_records.return_value = input_records 57 | 58 | # 2. Call the collector 59 | metrics = list(UserCollector.collect(mock_router_entry)) 60 | 61 | # 3. Assert the results 62 | assert len(metrics) == 1 63 | 64 | user_metric = metrics[0] 65 | assert user_metric.name == 'mktxp_active_users_info' 66 | assert len(user_metric.samples) == expected_sample_count 67 | 68 | # Check that the correct samples are present 69 | sample_names = {s.labels['name'] for s in user_metric.samples} 70 | assert sample_names == expected_names 71 | -------------------------------------------------------------------------------- /mktxp/datasource/address_list_ds.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | 15 | from mktxp.datasource.base_ds import BaseDSProcessor 16 | 17 | 18 | class AddressListMetricsDataSource: 19 | """Address List Metrics data provider""" 20 | @staticmethod 21 | def metric_records(router_entry, address_lists, ip_version, *, metric_labels=None, translation_table=None): 22 | if metric_labels is None: 23 | metric_labels = [] 24 | 25 | all_records = [] 26 | try: 27 | api_path = f"/{ip_version}/firewall/address-list" 28 | resource = router_entry.api_connection.router_api().get_resource(api_path) 29 | for list_name in address_lists: 30 | # Use memory-safe fetching by querying specific list 31 | records = resource.get(list=list_name) 32 | all_records.extend(records) 33 | 34 | return BaseDSProcessor.trimmed_records(router_entry, router_records=all_records, 35 | metric_labels=metric_labels, translation_table=translation_table) 36 | except Exception as exc: 37 | print(f'Error getting Address List info from router {router_entry.router_name}@{router_entry.config_entry.hostname}: {exc}') 38 | return None 39 | 40 | @staticmethod 41 | def count_metric_records(router_entry, address_lists, ip_version): 42 | api_path = f'/{ip_version}/firewall/address-list' 43 | queries = { 44 | 'total': {}, 45 | 'dynamic': {'dynamic': 'yes'}, 46 | 'static': {'dynamic': 'no'} 47 | } 48 | 49 | # Count entries in all lists 50 | all_lists_counts = {} 51 | for count_type, query in queries.items(): 52 | count = BaseDSProcessor.count_records(router_entry, api_path=api_path, api_query=query) 53 | if count is None: 54 | return None # Some error occurred 55 | all_lists_counts[count_type] = count 56 | 57 | # Count entries in selected lists 58 | selected_lists_counts = {} 59 | for list_name in address_lists: 60 | selected_lists_counts[list_name] = {} 61 | for count_type, query in queries.items(): 62 | query['list'] = list_name 63 | count = BaseDSProcessor.count_records(router_entry, api_path=api_path, api_query=query) 64 | if count is None: 65 | return None # Some error occurred 66 | selected_lists_counts[list_name][count_type] = count 67 | 68 | return { 69 | 'all_lists': all_lists_counts, 70 | 'selected_lists': selected_lists_counts 71 | } 72 | -------------------------------------------------------------------------------- /mktxp/datasource/firewall_ds.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | # Copyright (c) 2020 Arseniy Kuznetsov 3 | # 4 | # This program is free software; you can redistribute it and/or 5 | # modify it under the terms of the GNU General Public License 6 | # as published by the Free Software Foundation; either version 2 7 | # of the License, or (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | 14 | 15 | from mktxp.datasource.base_ds import BaseDSProcessor 16 | from mktxp.flow.router_entry import RouterEntry 17 | 18 | TRANSLATION_TABLE = { 19 | 'comment': lambda value: value if value else '', 20 | 'log': lambda value: '1' if value == 'true' else '0' 21 | } 22 | 23 | class FirewallMetricsDataSource: 24 | ''' Firewall Metrics data provider, supports both IPv4 and IPv6 25 | ''' 26 | @staticmethod 27 | def metric_records(router_entry, *, metric_labels=None, matching_only=True, filter_path='filter', ipv6 = False): 28 | if metric_labels is None: 29 | metric_labels = [] 30 | try: 31 | ip_stack = 'ipv6' if ipv6 else 'ip' 32 | filter_paths = { 33 | 'filter': f'/{ip_stack}/firewall/filter', 34 | 'raw': f'/{ip_stack}/firewall/raw', 35 | 'nat': f'/{ip_stack}/firewall/nat', 36 | 'mangle': f'/{ip_stack}/firewall/mangle' 37 | } 38 | filter_path = filter_paths[filter_path] 39 | firewall_records = FirewallMetricsDataSource._get_records( 40 | router_entry, 41 | filter_path, 42 | {'stats': ''} if ipv6 else {'stats': '', 'all': ''}, 43 | matching_only=matching_only 44 | ) 45 | 46 | return BaseDSProcessor.trimmed_records(router_entry, router_records=firewall_records, metric_labels=metric_labels, translation_table=TRANSLATION_TABLE) 47 | except Exception as exc: 48 | print( 49 | f'Error getting {"IPv6" if ipv6 else "IPv4"} firewall filters info from router {router_entry.router_name}@{router_entry.config_entry.hostname}: {exc}' 50 | ) 51 | return None 52 | 53 | # helpers 54 | @staticmethod 55 | def _get_records(router_entry: RouterEntry, filter_path: str, args: dict, matching_only: bool = False): 56 | """ 57 | Get firewall records from a Mikrotik ROS device. 58 | :param router_entry: The ROS API entry used to connect to the API 59 | :param filter_path: The path to query the records for (e.g. /ip/firewall/filter) 60 | :param args: A dictionary of arguments to pass to the print function used for export. 61 | Looks like: '{'stats': '', 'all': ''}' 62 | """ 63 | firewall_records = router_entry.api_connection.router_api().get_resource(filter_path).call('print', args) 64 | if matching_only: 65 | firewall_records = [record for record in firewall_records if int(record.get('bytes', '0')) > 0] 66 | return firewall_records 67 | -------------------------------------------------------------------------------- /tests/utils/test_utils.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | from datetime import timedelta 15 | import pytest 16 | from mktxp.utils import utils 17 | from packaging.version import parse 18 | 19 | 20 | @pytest.mark.parametrize("time_string, expected", [ 21 | ("5w4d3h2m1s", timedelta(weeks=5, days=4, hours=3, minutes=2, seconds=1)), 22 | ("7w3s", timedelta(weeks=7, seconds=3)), 23 | ("8d2m", timedelta(days=8, minutes=2)), 24 | ("xyz", timedelta()), 25 | ]) 26 | def test_parse_mkt_uptime(time_string, expected): 27 | assert utils.parse_mkt_uptime(time_string) == int(expected.total_seconds()) 28 | 29 | 30 | @pytest.mark.parametrize("version_str, expected", [ 31 | ("abc", False), 32 | ("6.13", False), 33 | ("7.13", True), 34 | ("7.13 (stable)", True), 35 | ]) 36 | def test_routerOS7_version(version_str, expected): 37 | assert utils.routerOS7_version(version_str) == expected 38 | 39 | 40 | @pytest.mark.parametrize( 41 | "version_string, expected_version, expected_channel", 42 | [ 43 | ("2.1.5 (stable)", "2.1.5", "stable"), 44 | ("2.2.0 (long-term)", "2.2.0", "long-term"), 45 | ("2.3.1 (development)", "2.3.1", "development"), 46 | ("2.4.0 (testing)", "2.4.0", "testing"), 47 | ("2.5.5", "2.5.5", "stable"), 48 | ], 49 | ) 50 | def test_parse_ros_version(version_string, expected_version, expected_channel): 51 | version, channel = utils.parse_ros_version(version_string) 52 | assert version == parse(expected_version) 53 | assert channel == expected_channel 54 | 55 | 56 | def test_parse_ros_version_invalid(): 57 | with pytest.raises(ValueError): 58 | utils.parse_ros_version("invalid_version") 59 | 60 | @pytest.mark.parametrize("str_value, expected", [ 61 | ("y", True), 62 | ("yes", True), 63 | ("t", True), 64 | ("true", True), 65 | ("on", True), 66 | ("ok", True), 67 | ("1", True), 68 | ("n", False), 69 | ("no", False), 70 | ("f", False), 71 | ("false", False), 72 | ("off", False), 73 | ("fail", False), 74 | ("0", False), 75 | (0, False), 76 | (1, False), 77 | ([], False), 78 | ({}, False), 79 | ]) 80 | def test_str2bool_true_false_return(str_value, expected): 81 | assert utils.str2bool(str_value) == expected 82 | assert utils.str2bool(str_value, False) == expected 83 | assert utils.str2bool(str_value, True) == expected 84 | 85 | @pytest.mark.parametrize("str_value", [ 86 | "abc", 87 | "p", 88 | "x", 89 | "10", 90 | ]) 91 | def test_str2bool_raise_value_error(str_value): 92 | with pytest.raises(ValueError): 93 | utils.str2bool(str_value) 94 | 95 | assert utils.str2bool(str_value, False) == False 96 | assert utils.str2bool(str_value, True) == True 97 | -------------------------------------------------------------------------------- /mktxp/collector/dhcp_collector.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | 15 | from mktxp.cli.config.config import MKTXPConfigKeys 16 | from mktxp.collector.base_collector import BaseCollector 17 | from mktxp.datasource.dhcp_ds import DHCPMetricsDataSource 18 | 19 | 20 | class DHCPCollector(BaseCollector): 21 | ''' DHCP Metrics collector 22 | ''' 23 | @staticmethod 24 | def collect(router_entry): 25 | if not router_entry.config_entry.dhcp: 26 | return 27 | 28 | dhcp_lease_labels = ['active_address', 'address', 'mac_address', 'host_name', 'comment', 'server', 'expires_after', 'client_id', 'active_mac_address'] 29 | dhcp_lease_records = DHCPMetricsDataSource.metric_records(router_entry, metric_labels = dhcp_lease_labels) 30 | if dhcp_lease_records: 31 | # calculate number of leases per DHCP server 32 | dhcp_lease_servers = {} 33 | for dhcp_lease_record in dhcp_lease_records: 34 | if dhcp_lease_record.get('server'): 35 | dhcp_lease_servers[dhcp_lease_record['server']] = dhcp_lease_servers.get(dhcp_lease_record['server'], 0) + 1 36 | 37 | # compile leases-per-server records 38 | dhcp_lease_servers_records = [{ MKTXPConfigKeys.ROUTERBOARD_NAME: router_entry.router_id[MKTXPConfigKeys.ROUTERBOARD_NAME], 39 | MKTXPConfigKeys.ROUTERBOARD_ADDRESS: router_entry.router_id[MKTXPConfigKeys.ROUTERBOARD_ADDRESS], 40 | 'server': key, 'count': value} for key, value in dhcp_lease_servers.items()] 41 | 42 | # yield lease-per-server metrics 43 | dhcp_lease_server_metrics = BaseCollector.gauge_collector('dhcp_lease_active_count', 44 | 'Number of active leases per DHCP server', 45 | dhcp_lease_servers_records, 'count', ['server']) 46 | yield dhcp_lease_server_metrics 47 | 48 | # active lease metrics 49 | dhcp_lease_labels.remove('expires_after') 50 | if router_entry.config_entry.dhcp_lease: 51 | dhcp_lease_metrics_gauge = BaseCollector.gauge_collector('dhcp_lease_info', 'DHCP Active Leases', 52 | dhcp_lease_records, 'expires_after', dhcp_lease_labels) 53 | yield dhcp_lease_metrics_gauge 54 | 55 | # active lease metrics 56 | #if router_entry.config_entry.dhcp_lease: 57 | # dhcp_lease_metrics = BaseCollector.info_collector('dhcp_lease', 'DHCP Active Leases', dhcp_lease_records, dhcp_lease_labels) 58 | # yield dhcp_lease_metrics 59 | -------------------------------------------------------------------------------- /mktxp/collector/kid_control_device_collector.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | 15 | from mktxp.collector.base_collector import BaseCollector 16 | from mktxp.flow.processor.output import BaseOutputProcessor 17 | from mktxp.datasource.kid_control_device_ds import KidDeviceMetricsDataSource 18 | 19 | 20 | class KidDeviceCollector(BaseCollector): 21 | """ Kid-control device Metrics collector 22 | """ 23 | 24 | @staticmethod 25 | def collect(router_entry): 26 | if not (router_entry.config_entry.kid_control_assigned or router_entry.config_entry.kid_control_dynamic): 27 | return 28 | 29 | labels = ['name', 'user', 'mac_address', 'ip_address', 'bytes_down', 'bytes_up', 'rate_up', 30 | 'rate_down','bytes_up', 'idle_time','blocked', 'limited', 'inactive', 'disabled'] 31 | 32 | translation_table = { 33 | 'rate_up': lambda value: BaseOutputProcessor.parse_rates(value), 34 | 'rate_down': lambda value: BaseOutputProcessor.parse_rates(value), 35 | 'idle_time': lambda value: BaseOutputProcessor.parse_timedelta_seconds(value) if value else 0, 36 | 'blocked': lambda value: '1' if value == 'true' else '0', 37 | 'limited': lambda value: '1' if value == 'true' else '0', 38 | 'inactive': lambda value: '1' if value == 'true' else '0', 39 | 'disabled': lambda value: '1' if value == 'true' else '0'} 40 | 41 | records = KidDeviceMetricsDataSource.metric_records(router_entry, metric_labels=labels, translation_table=translation_table) 42 | if records: 43 | # dhcp resolution 44 | for registration_record in records: 45 | BaseOutputProcessor.resolve_dhcp(router_entry, registration_record, resolve_address=False) 46 | 47 | info_labels = ['name', 'dhcp_name', 'mac_address', 'user', 'ip_address', 'disabled'] 48 | yield BaseCollector.info_collector('kid_control_device', 'Kid-control device Info', records, info_labels) 49 | 50 | id_labels = ['name', 'dhcp_name', 'mac_address', 'user'] 51 | yield BaseCollector.counter_collector('kid_control_device_bytes_down', 'Number of received bytes', records, 'bytes_down', id_labels) 52 | yield BaseCollector.counter_collector('kid_control_device_bytes_up', 'Number of transmitted bytes', records, 'bytes_up', id_labels) 53 | 54 | yield BaseCollector.gauge_collector('kid_control_device_rate_up', 'Device rate up', records, 'rate_up', id_labels) 55 | yield BaseCollector.gauge_collector('kid_control_device_idle_time', 'Device idle time', records, 'idle_time', id_labels) 56 | yield BaseCollector.gauge_collector('kid_control_device_rate_down', 'Device rate down', records, 'rate_down', id_labels) 57 | -------------------------------------------------------------------------------- /mktxp/datasource/poe_ds.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | 15 | from mktxp.datasource.base_ds import BaseDSProcessor 16 | from mktxp.flow.processor.output import BaseOutputProcessor 17 | 18 | 19 | class POEMetricsDataSource: 20 | ''' POE Metrics data provider 21 | ''' 22 | @staticmethod 23 | def metric_records(router_entry, *, metric_labels = None): 24 | if metric_labels is None: 25 | metric_labels = [] 26 | try: 27 | poe_records = router_entry.api_connection.router_api().get_resource('/interface/ethernet/poe').get() 28 | for int_num, poe_record in enumerate(poe_records): 29 | poe_monitor_records = router_entry.api_connection.router_api().get_resource('/interface/ethernet/poe').call('monitor', {'once':'', 'numbers':f'{int_num}'}) 30 | poe_monitor_records = BaseDSProcessor.trimmed_records(router_entry, router_records = poe_monitor_records) 31 | 32 | if poe_monitor_records[0].get('poe_out_status'): 33 | poe_record['poe_out_status'] = poe_monitor_records[0]['poe_out_status'] 34 | 35 | if poe_monitor_records[0].get('poe_out_voltage'): 36 | poe_record['poe_out_voltage'] = poe_monitor_records[0]['poe_out_voltage'] 37 | 38 | if poe_monitor_records[0].get('poe_out_current'): 39 | poe_record['poe_out_current'] = poe_monitor_records[0]['poe_out_current'] 40 | 41 | if poe_monitor_records[0].get('poe_out_power'): 42 | poe_record['poe_out_power'] = poe_monitor_records[0]['poe_out_power'] 43 | 44 | # Apply interface name formatting based on config 45 | interfaces = router_entry.api_connection.router_api().get_resource('/interface/ethernet').call('print', {'proplist':'name,comment'}) 46 | comment_fn = lambda interface: interface['comment'] if interface.get('comment') else '' 47 | for poe_record in poe_records: 48 | comment = [comment_fn(interface) for interface in interfaces if interface['name'] == poe_record['name']][0] 49 | if comment: 50 | # Format name with comment using centralized function 51 | poe_record['name'] = BaseOutputProcessor.format_interface_name( 52 | poe_record['name'], 53 | comment, 54 | router_entry.config_entry.interface_name_format 55 | ) 56 | return BaseDSProcessor.trimmed_records(router_entry, router_records = poe_records, metric_labels = metric_labels) 57 | except Exception as exc: 58 | print(f'Error getting PoE info from router {router_entry.router_name}@{router_entry.config_entry.hostname}: {exc}') 59 | return None 60 | 61 | -------------------------------------------------------------------------------- /mktxp/flow/router_entries_handler.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | from mktxp.cli.config.config import config_handler 15 | from mktxp.flow.router_entry import RouterEntry 16 | from mktxp.flow.router_connection import RouterAPIConnectionError 17 | 18 | class RouterEntriesHandler: 19 | ''' Handles RouterOS entries defined in MKTXP config 20 | ''' 21 | def __init__(self): 22 | self._router_entries = {} 23 | for router_name in config_handler.registered_entries(): 24 | router_entry = RouterEntry(router_name) 25 | RouterEntriesHandler._set_child_entries(router_entry) 26 | self._router_entries[router_name] = router_entry 27 | 28 | @property 29 | def router_entries(self): 30 | return (entry for key, entry in self._router_entries.items() if entry.config_entry.enabled) \ 31 | if self._router_entries else None 32 | 33 | @staticmethod 34 | def router_entry(entry_name, enabled_only = False): 35 | ''' A static router entry initialiser 36 | ''' 37 | config_entry = config_handler.config_entry(entry_name) 38 | if enabled_only and not config_entry.enabled: 39 | return None 40 | 41 | router_entry = RouterEntry(entry_name) 42 | RouterEntriesHandler._set_child_entries(router_entry) 43 | try: 44 | router_entry.connect() 45 | except RouterAPIConnectionError as exc: 46 | print (f'{exc}') 47 | return router_entry 48 | 49 | @staticmethod 50 | def _set_child_entries(router_entry): 51 | if router_entry.config_entry.remote_dhcp_entry and config_handler.registered_entry(router_entry.config_entry.remote_dhcp_entry): 52 | router_entry.dhcp_entry = RouterEntry(router_entry.config_entry.remote_dhcp_entry) 53 | else: 54 | remote_dhcp_entry_name = router_entry.config_entry.remote_dhcp_entry 55 | if remote_dhcp_entry_name != 'None': 56 | print(f"Error in configuration for {router_entry.router_name}: remote_dhcp_entry must a name of another router entry or 'None', but it is '{remote_dhcp_entry_name}'. Ignoring.") 57 | 58 | if router_entry.config_entry.remote_capsman_entry and config_handler.registered_entry(router_entry.config_entry.remote_capsman_entry): 59 | router_entry.capsman_entry = RouterEntry(router_entry.config_entry.remote_capsman_entry) 60 | else: 61 | remote_capsman_entry_name = router_entry.config_entry.remote_capsman_entry 62 | if remote_capsman_entry_name != 'None': 63 | print(f"Error in configuration for {router_entry.router_name}: remote_capsman_entry must a name of another router entry or 'None', but it is '{remote_capsman_entry_name}'. Ignoring.") 64 | 65 | -------------------------------------------------------------------------------- /mktxp/collector/bgp_collector.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | 15 | from mktxp.collector.base_collector import BaseCollector 16 | from mktxp.flow.processor.output import BaseOutputProcessor 17 | from mktxp.datasource.bgp_ds import BGPMetricsDataSource 18 | 19 | 20 | class BGPCollector(BaseCollector): 21 | '''BGP collector''' 22 | @staticmethod 23 | def collect(router_entry): 24 | if not router_entry.config_entry.bgp: 25 | return 26 | 27 | bgp_labels = ['name', 'remote_address', 'remote_as', 'local_as', 'remote_afi', 'local_afi', 'remote_messages', 'remote_bytes', 'local_messages', 'local_bytes', 'prefix_count', 'established', 'uptime'] 28 | translation_table = { 29 | 'established': lambda value: '1' if value=='true' else '0', 30 | 'uptime': lambda value: BaseOutputProcessor.parse_timedelta_milliseconds(value) if value else '0' 31 | } 32 | bgp_records = BGPMetricsDataSource.metric_records(router_entry, metric_labels=bgp_labels, translation_table = translation_table) 33 | 34 | if bgp_records: 35 | session_info_labes = ['name', 'remote_address', 'remote_as', 'local_as', 'remote_afi', 'local_afi'] 36 | bgp_sessions_metrics = BaseCollector.info_collector('bgp_sessions', 'BGP sessions info', bgp_records, session_info_labes) 37 | yield bgp_sessions_metrics 38 | 39 | session_id_labes = ['name'] 40 | remote_messages_metrics = BaseCollector.counter_collector('bgp_remote_messages', 'Number of remote messages', bgp_records, 'remote_messages', session_id_labes) 41 | yield remote_messages_metrics 42 | 43 | 44 | local_messages_metrics = BaseCollector.counter_collector('bgp_local_messages', 'Number of local messages', bgp_records, 'local_messages', session_id_labes) 45 | yield local_messages_metrics 46 | 47 | 48 | remote_bytes_metrics = BaseCollector.counter_collector('bgp_remote_bytes', 'Number of remote bytes', bgp_records, 'remote_bytes', session_id_labes) 49 | yield remote_bytes_metrics 50 | 51 | 52 | local_bytes_metrics = BaseCollector.counter_collector('bgp_local_bytes', 'Number of local bytes', bgp_records, 'local_bytes', session_id_labes) 53 | yield local_bytes_metrics 54 | 55 | 56 | prefix_count_metrics = BaseCollector.gauge_collector('bgp_prefix_count', 'BGP prefix count', bgp_records, 'prefix_count', session_id_labes) 57 | yield prefix_count_metrics 58 | 59 | 60 | established_metrics = BaseCollector.gauge_collector('bgp_established', 'BGP established', bgp_records, 'established', session_id_labes) 61 | yield established_metrics 62 | 63 | 64 | uptime_metrics = BaseCollector.gauge_collector('bgp_uptime', 'BGP uptime in milliseconds', bgp_records, 'uptime', session_id_labes) 65 | yield uptime_metrics 66 | 67 | -------------------------------------------------------------------------------- /mktxp/collector/routing_stats_collector.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | from mktxp.collector.base_collector import BaseCollector 15 | from mktxp.datasource.routing_stats_ds import RoutingStatsMetricsDataSource 16 | from mktxp.utils.utils import parse_mkt_uptime 17 | 18 | 19 | class RoutingStatsCollector(BaseCollector): 20 | '''Routing Stats collector''' 21 | @staticmethod 22 | def collect(router_entry): 23 | if not router_entry.config_entry.routing_stats: 24 | return 25 | 26 | routing_stats_labels = ['tasks', 'id', 'private_mem_blocks', 'shared_mem_blocks', 'kernel_time', 'process_time', 'max_busy', 'max_calc'] 27 | translation_table = { 28 | 'kernel_time': lambda value: parse_mkt_uptime(value), 29 | 'process_time': lambda value: parse_mkt_uptime(value), 30 | 'max_busy': lambda value: parse_mkt_uptime(value), 31 | 'max_calc': lambda value: parse_mkt_uptime(value), 32 | } 33 | routing_stats_records = RoutingStatsMetricsDataSource.metric_records(router_entry, metric_labels=routing_stats_labels, translation_table = translation_table) 34 | 35 | if routing_stats_records: 36 | session_info_labels = ['tasks', 'id', 'pid'] 37 | routing_stats_processes_metrics = BaseCollector.info_collector('routing_stats_processes', 'Routing Process Stats', routing_stats_records, session_info_labels) 38 | yield routing_stats_processes_metrics 39 | 40 | session_id_labels = ['tasks'] 41 | routing_stats_private_mem_metrics = BaseCollector.gauge_collector('routing_stats_private_mem', 'Private Memory Blocks Used', routing_stats_records, 'private_mem_blocks', session_id_labels) 42 | yield routing_stats_private_mem_metrics 43 | 44 | routing_stats_shared_mem_metrics = BaseCollector.gauge_collector('routing_stats_shared_mem', 'Shared Memory Blocks Used', routing_stats_records, 'shared_mem_blocks', session_id_labels) 45 | yield routing_stats_shared_mem_metrics 46 | 47 | kernel_time_metrics = BaseCollector.counter_collector('routing_stats_kernel_time', 'Process Kernel Time', routing_stats_records, 'kernel_time', session_id_labels) 48 | yield kernel_time_metrics 49 | 50 | process_time_metrics = BaseCollector.counter_collector('routing_stats_process_time', 'Process Time', routing_stats_records, 'process_time', session_id_labels) 51 | yield process_time_metrics 52 | 53 | max_busy_metrics = BaseCollector.counter_collector('routing_stats_max_busy', 'Max Busy Time', routing_stats_records, 'max_busy', session_id_labels) 54 | yield max_busy_metrics 55 | 56 | max_calc_metrics = BaseCollector.counter_collector('routing_stats_max_calc', 'Max Calc Time', routing_stats_records, 'max_calc', session_id_labels) 57 | yield max_calc_metrics 58 | -------------------------------------------------------------------------------- /mktxp/collector/bandwidth_collector.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | 15 | import socket 16 | import speedtest 17 | from datetime import datetime 18 | from multiprocessing import Pool, get_context 19 | from mktxp.cli.config.config import config_handler 20 | from mktxp.collector.base_collector import BaseCollector 21 | 22 | 23 | result_list = [{'download': 0, 'upload': 0, 'ping': 0}] 24 | def get_result(bandwidth_dict): 25 | result_list[0] = bandwidth_dict 26 | 27 | 28 | class BandwidthCollector(BaseCollector): 29 | ''' MKTXP collector 30 | ''' 31 | def __init__(self): 32 | self.pool = None 33 | self.last_call_timestamp = 0 34 | 35 | def collect(self): 36 | if not config_handler.system_entry.bandwidth: 37 | return 38 | 39 | if self.pool is None: 40 | self.pool = get_context("spawn").Pool() 41 | 42 | if result_list: 43 | result_dict = result_list[0] 44 | bandwidth_records = [{'direction': key, 'bandwidth': str(result_dict[key])} for key in ('download', 'upload')] 45 | bandwidth_metrics = BaseCollector.gauge_collector('internet_bandwidth', 'Internet bandwidth in bits per second', 46 | bandwidth_records, 'bandwidth', ['direction'], add_id_labels = False) 47 | yield bandwidth_metrics 48 | 49 | latency_records = [{'latency': str(result_dict['ping'])}] 50 | latency_metrics = BaseCollector.gauge_collector('internet_latency', 'Internet latency in milliseconds', 51 | latency_records, 'latency', [], add_id_labels = False) 52 | yield latency_metrics 53 | 54 | ts = datetime.now().timestamp() 55 | if (ts - self.last_call_timestamp) > config_handler.system_entry.bandwidth_test_interval: 56 | self.pool.apply_async(BandwidthCollector.bandwidth_worker, callback=get_result) 57 | self.last_call_timestamp = ts 58 | 59 | def __del__(self): 60 | if self.pool is not None: 61 | self.pool.close() 62 | self.pool.join() 63 | 64 | @staticmethod 65 | def bandwidth_worker(): 66 | if BandwidthCollector.inet_connected(): 67 | bandwidth_test = speedtest.Speedtest() 68 | bandwidth_test.get_best_server() 69 | bandwidth_test.download() 70 | bandwidth_test.upload() 71 | return bandwidth_test.results.dict() 72 | else: 73 | return {'download': 0, 'upload': 0, 'ping': 0} 74 | 75 | @staticmethod 76 | def inet_connected(host=None, port=53, timeout=3): 77 | host = host or config_handler.system_entry.bandwidth_test_dns_server 78 | try: 79 | socket.setdefaulttimeout(timeout) 80 | socket.socket(socket.AF_INET, socket.SOCK_STREAM).connect((host, port)) 81 | return True 82 | except socket.error as exc: 83 | return False 84 | 85 | -------------------------------------------------------------------------------- /mktxp/datasource/connection_ds.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | 15 | from collections import namedtuple 16 | from mktxp.datasource.base_ds import BaseDSProcessor 17 | 18 | 19 | class IPConnectionDatasource: 20 | ''' IP connections data provider 21 | ''' 22 | @staticmethod 23 | def metric_records(router_entry, *, metric_labels = None): 24 | if metric_labels is None: 25 | metric_labels = [] 26 | try: 27 | res = router_entry.api_connection.router_api().get_resource('/ip/firewall/connection/').call('print', {'count-only': ''}) 28 | # result processing as described at: https://github.com/socialwifi/RouterOS-api/issues/79#issuecomment-2089744809 29 | cnt_str = res.done_message.get('ret') 30 | try: 31 | count = int(cnt_str) 32 | except (ValueError, TypeError): 33 | cnt_str = '0' 34 | records = [{'count': cnt_str}] 35 | return BaseDSProcessor.trimmed_records(router_entry, router_records = records, metric_labels = metric_labels) 36 | except Exception as exc: 37 | print(f'Error getting IP connection info from router {router_entry.router_name}@{router_entry.config_entry.hostname}: {exc}') 38 | return None 39 | 40 | 41 | class IPConnectionStatsDatasource: 42 | ''' IP connections stats data provider 43 | ''' 44 | @staticmethod 45 | def metric_records(router_entry, *, metric_labels = None, add_router_id = True): 46 | if metric_labels is None: 47 | metric_labels = [] 48 | try: 49 | # First, check if there are any connections 50 | count_records = IPConnectionDatasource.metric_records(router_entry) 51 | if count_records[0].get('count', 0) == '0': 52 | return [] 53 | 54 | connection_records = router_entry.api_connection.router_api().get_resource('/ip/firewall/connection/').call('print', \ 55 | {'proplist':'src-address,dst-address,protocol'}) 56 | # calculate number of connections per src-address 57 | connections_per_src_address = {} 58 | for connection_record in connection_records: 59 | address = connection_record['src-address'].split(':')[0] 60 | destination = f"{connection_record.get('dst-address')}({connection_record.get('protocol')})" 61 | 62 | count, destinations = 0, set() 63 | if connections_per_src_address.get(address): 64 | count, destinations = connections_per_src_address[address] 65 | count += 1 66 | destinations.add(destination) 67 | connections_per_src_address[address] = ConnStatsEntry(count, destinations) 68 | 69 | # compile connections-per-interface records 70 | records = [] 71 | for key, entry in connections_per_src_address.items(): 72 | record = {'src_address': key, 'connection_count': entry.count, 'dst_addresses': ', '.join(entry.destinations)} 73 | if add_router_id: 74 | for router_key, router_value in router_entry.router_id.items(): 75 | record[router_key] = router_value 76 | records.append(record) 77 | return records 78 | except Exception as exc: 79 | print(f'Error getting IP connection stats info from router {router_entry.router_name}@{router_entry.config_entry.hostname}: {exc}') 80 | return None 81 | 82 | 83 | ConnStatsEntry = namedtuple('ConnStatsEntry', ['count', 'destinations']) -------------------------------------------------------------------------------- /mktxp/collector/resource_collector.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | 15 | from mktxp.collector.base_collector import BaseCollector 16 | from mktxp.flow.processor.output import BaseOutputProcessor 17 | from mktxp.datasource.system_resource_ds import SystemResourceMetricsDataSource 18 | from mktxp.utils.utils import check_for_updates 19 | 20 | 21 | class SystemResourceCollector(BaseCollector): 22 | ''' System Resource Metrics collector 23 | ''' 24 | @staticmethod 25 | def collect(router_entry): 26 | resource_labels = ['uptime', 'version', 'free_memory', 'total_memory', 27 | 'cpu', 'cpu_count', 'cpu_frequency', 'cpu_load', 28 | 'free_hdd_space', 'total_hdd_space', 29 | 'architecture_name', 'board_name'] 30 | translation_table = {'uptime': lambda value: BaseOutputProcessor.parse_timedelta_seconds(value)} 31 | 32 | resource_records = SystemResourceMetricsDataSource.metric_records(router_entry, metric_labels = resource_labels, translation_table=translation_table) 33 | if resource_records: 34 | uptime_metrics = BaseCollector.gauge_collector('system_uptime', 'Time interval since boot-up', resource_records, 'uptime', ['version', 'board_name', 'cpu', 'architecture_name']) 35 | yield uptime_metrics 36 | 37 | free_memory_metrics = BaseCollector.gauge_collector('system_free_memory', 'Unused amount of RAM', resource_records, 'free_memory', ['version', 'board_name', 'cpu', 'architecture_name']) 38 | yield free_memory_metrics 39 | 40 | total_memory_metrics = BaseCollector.gauge_collector('system_total_memory', 'Amount of installed RAM', resource_records, 'total_memory', ['version', 'board_name', 'cpu', 'architecture_name']) 41 | yield total_memory_metrics 42 | 43 | free_hdd_metrics = BaseCollector.gauge_collector('system_free_hdd_space', 'Free space on hard drive or NAND', resource_records, 'free_hdd_space', ['version', 'board_name', 'cpu', 'architecture_name']) 44 | yield free_hdd_metrics 45 | 46 | total_hdd_metrics = BaseCollector.gauge_collector('system_total_hdd_space', 'Size of the hard drive or NAND', resource_records, 'total_hdd_space', ['version', 'board_name', 'cpu', 'architecture_name']) 47 | yield total_hdd_metrics 48 | 49 | cpu_load_metrics = BaseCollector.gauge_collector('system_cpu_load', 'Percentage of used CPU resources', resource_records, 'cpu_load', ['version', 'board_name', 'cpu', 'architecture_name']) 50 | yield cpu_load_metrics 51 | 52 | cpu_count_metrics = BaseCollector.gauge_collector('system_cpu_count', 'Number of CPUs present on the system', resource_records, 'cpu_count', ['version', 'board_name', 'cpu', 'architecture_name']) 53 | yield cpu_count_metrics 54 | 55 | cpu_frequency_metrics = BaseCollector.gauge_collector('system_cpu_frequency', 'Current CPU frequency', resource_records, 'cpu_frequency', ['version', 'board_name', 'cpu', 'architecture_name']) 56 | yield cpu_frequency_metrics 57 | 58 | # Check for updates 59 | if router_entry.config_entry.check_for_updates: 60 | for record in resource_records: 61 | cur_version, newest_version = check_for_updates(record['version']) 62 | record['newest_version'] = str(newest_version) 63 | record['update_available'] = cur_version < newest_version 64 | 65 | update_available_metrics = BaseCollector.gauge_collector('system_update_available', 'Is there a newer version available', resource_records, 'update_available', ['newest_version',]) 66 | yield update_available_metrics 67 | -------------------------------------------------------------------------------- /mktxp/collector/queue_collector.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | 15 | from mktxp.collector.base_collector import BaseCollector 16 | from mktxp.datasource.queue_ds import QueueMetricsDataSource 17 | 18 | 19 | class QueueTreeCollector(BaseCollector): 20 | '''Queue Tree collector''' 21 | @staticmethod 22 | def collect(router_entry): 23 | if not router_entry.config_entry.queue: 24 | return 25 | 26 | qt_labels = ['name', 'parent', 'packet_mark', 'limit_at', 'max_limit', 'priority', 'bytes', 'queued_bytes', 'dropped', 'rate', 'disabled'] 27 | qt_records = QueueMetricsDataSource.metric_records(router_entry, metric_labels=qt_labels, kind = 'tree') 28 | 29 | if qt_records: 30 | qt_rate_metric = BaseCollector.counter_collector('queue_tree_rates', 'Average passing data rate in bytes per second', qt_records, 'rate', ['name']) 31 | yield qt_rate_metric 32 | 33 | qt_byte_metric = BaseCollector.counter_collector('queue_tree_bytes', 'Number of processed bytes', qt_records, 'bytes', ['name']) 34 | yield qt_byte_metric 35 | 36 | qt_queued_metric = BaseCollector.counter_collector('queue_tree_queued_bytes', 'Number of queued bytes', qt_records, 'queued_bytes', ['name']) 37 | yield qt_queued_metric 38 | 39 | 40 | qt_drop_metric = BaseCollector.counter_collector('queue_tree_dropped', 'Number of dropped bytes', qt_records, 'dropped', ['name']) 41 | yield qt_drop_metric 42 | 43 | 44 | class QueueSimpleCollector(BaseCollector): 45 | '''Simple Queue collector''' 46 | @staticmethod 47 | def collect(router_entry): 48 | if not router_entry.config_entry.queue: 49 | return 50 | 51 | qt_labels = ['name', 'parent', 'packet_mark', 'limit_at', 'max_limit', 'priority', 'bytes', 'packets', 'queued_bytes', 'queued_packets','dropped', 'rate', 'packet_rate', 'disabled'] 52 | qt_records = QueueMetricsDataSource.metric_records(router_entry, metric_labels=qt_labels, kind = 'simple') 53 | 54 | if qt_records: 55 | qt_rate_metric = BaseCollector.counter_collector('queue_simple_rates_upload', 'Average passing upload data rate in bytes per second', qt_records, 'rate_up', ['name']) 56 | yield qt_rate_metric 57 | 58 | qt_rate_metric = BaseCollector.counter_collector('queue_simple_rates_download', 'Average passing download data rate in bytes per second', qt_records, 'rate_down', ['name']) 59 | yield qt_rate_metric 60 | 61 | 62 | qt_byte_metric = BaseCollector.counter_collector('queue_simple_bytes_upload', 'Number of upload processed bytes', qt_records, 'bytes_up', ['name']) 63 | yield qt_byte_metric 64 | 65 | qt_byte_metric = BaseCollector.counter_collector('queue_simple_bytes_download', 'Number of download processed bytes', qt_records, 'bytes_down', ['name']) 66 | yield qt_byte_metric 67 | 68 | 69 | qt_queued_metric = BaseCollector.counter_collector('queue_simple_queued_bytes_upload', 'Number of upload queued bytes', qt_records, 'queued_bytes_up', ['name']) 70 | yield qt_queued_metric 71 | 72 | 73 | qt_queued_metric = BaseCollector.counter_collector('queue_simple_queued_bytes_downloadd', 'Number of download queued bytes', qt_records, 'queued_bytes_down', ['name']) 74 | yield qt_queued_metric 75 | 76 | 77 | qt_drop_metric = BaseCollector.counter_collector('queue_simple_dropped_upload', 'Number of upload dropped bytes', qt_records, 'dropped_up', ['name']) 78 | yield qt_drop_metric 79 | 80 | 81 | qt_drop_metric = BaseCollector.counter_collector('queue_simple_dropped_download', 'Number of download dropped bytes', qt_records, 'dropped_down', ['name']) 82 | yield qt_drop_metric 83 | 84 | -------------------------------------------------------------------------------- /mktxp/collector/route_collector.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | 15 | from mktxp.cli.config.config import MKTXPConfigKeys 16 | from mktxp.collector.base_collector import BaseCollector 17 | from mktxp.datasource.route_ds import RouteMetricsDataSource 18 | 19 | 20 | class RouteCollector(BaseCollector): 21 | ''' IP Route Metrics collector 22 | ''' 23 | @staticmethod 24 | def collect(router_entry): 25 | route_labels = ['connect', 'dynamic', 'static', 'bgp', 'ospf'] 26 | 27 | # ~*~*~*~*~*~ IPv4 ~*~*~*~*~*~ 28 | if router_entry.config_entry.route: 29 | route_counts = RouteMetricsDataSource.metric_records(router_entry, metric_labels=route_labels) 30 | if route_counts: 31 | # compile total routes records 32 | total_routes_records = [{ 33 | MKTXPConfigKeys.ROUTERBOARD_NAME: router_entry.router_id[MKTXPConfigKeys.ROUTERBOARD_NAME], 34 | MKTXPConfigKeys.ROUTERBOARD_ADDRESS: router_entry.router_id[MKTXPConfigKeys.ROUTERBOARD_ADDRESS], 35 | 'count': route_counts['total_routes'] 36 | }] 37 | total_routes_metrics = BaseCollector.gauge_collector('routes_total_routes', 'Overall number of routes in RIB', total_routes_records, 'count') 38 | yield total_routes_metrics 39 | 40 | # compile route-per-protocol records 41 | route_per_protocol_records = [{ 42 | MKTXPConfigKeys.ROUTERBOARD_NAME: router_entry.router_id[MKTXPConfigKeys.ROUTERBOARD_NAME], 43 | MKTXPConfigKeys.ROUTERBOARD_ADDRESS: router_entry.router_id[MKTXPConfigKeys.ROUTERBOARD_ADDRESS], 44 | 'protocol': key, 'count': value 45 | } for key, value in route_counts['routes_per_protocol'].items()] 46 | 47 | # yield route-per-protocol metrics 48 | route_per_protocol_metrics = BaseCollector.gauge_collector('routes_protocol_count', 'Number of routes per protocol in RIB', route_per_protocol_records, 'count', ['protocol']) 49 | yield route_per_protocol_metrics 50 | 51 | # ~*~*~*~*~*~ IPv6 ~*~*~*~*~*~ 52 | if router_entry.config_entry.ipv6_route: 53 | route_counts = RouteMetricsDataSource.metric_records(router_entry, metric_labels=route_labels, ipv6=True) 54 | if route_counts: 55 | # compile total routes records 56 | total_routes_records = [{ 57 | MKTXPConfigKeys.ROUTERBOARD_NAME: router_entry.router_id[MKTXPConfigKeys.ROUTERBOARD_NAME], 58 | MKTXPConfigKeys.ROUTERBOARD_ADDRESS: router_entry.router_id[MKTXPConfigKeys.ROUTERBOARD_ADDRESS], 59 | 'count': route_counts['total_routes'] 60 | }] 61 | total_routes_metrics = BaseCollector.gauge_collector('routes_total_routes_ipv6', 'Overall number of routes in RIB (IPv6)', total_routes_records, 'count') 62 | yield total_routes_metrics 63 | 64 | # compile route-per-protocol records 65 | route_per_protocol_records = [{ 66 | MKTXPConfigKeys.ROUTERBOARD_NAME: router_entry.router_id[MKTXPConfigKeys.ROUTERBOARD_NAME], 67 | MKTXPConfigKeys.ROUTERBOARD_ADDRESS: router_entry.router_id[MKTXPConfigKeys.ROUTERBOARD_ADDRESS], 68 | 'protocol': key, 'count': value 69 | } for key, value in route_counts['routes_per_protocol'].items()] 70 | 71 | # yield route-per-protocol metrics 72 | route_per_protocol_metrics = BaseCollector.gauge_collector('routes_protocol_count_ipv6', 'Number of routes per protocol in RIB (IPv6)', route_per_protocol_records, 'count', ['protocol']) 73 | yield route_per_protocol_metrics 74 | 75 | -------------------------------------------------------------------------------- /mktxp/cli/output/netwatch_out.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | 15 | from mktxp.flow.processor.output import BaseOutputProcessor 16 | from mktxp.datasource.netwatch_ds import NetwatchMetricsDataSource 17 | from humanize import naturaldelta 18 | 19 | class NetwatchOutput: 20 | ''' Netwatch CLI Output 21 | ''' 22 | 23 | @staticmethod 24 | def clients_summary(router_entry): 25 | ''' Display netwatch summary 26 | ''' 27 | print(f'{router_entry.router_name}@{router_entry.config_entry.hostname}: OK to connect') 28 | print(f'Connecting to router {router_entry.router_name}@{router_entry.config_entry.hostname}') 29 | 30 | # Collect netwatch data 31 | netwatch_records = NetwatchOutput._collect_records(router_entry) 32 | 33 | if not netwatch_records: 34 | print('No netwatch entries found') 35 | return 36 | 37 | # Display table 38 | NetwatchOutput._display_table(netwatch_records) 39 | 40 | @staticmethod 41 | def _collect_records(router_entry): 42 | ''' Collect netwatch records 43 | ''' 44 | metric_labels = ['name', 'host', 'type', 'status', 'since', 'timeout', 'interval', 'comment'] 45 | translation_table = { 46 | 'status': lambda value: 'Up' if value == 'up' else 'Down', 47 | 'since': lambda value: value if value else '', 48 | 'timeout': lambda value: value if value else '', 49 | 'interval': lambda value: value if value else '', 50 | 'comment': lambda value: value if value else '' 51 | } 52 | 53 | try: 54 | records = NetwatchMetricsDataSource.metric_records( 55 | router_entry, 56 | metric_labels=metric_labels, 57 | translation_table=translation_table 58 | ) 59 | return records if records else [] 60 | except Exception as exc: 61 | print(f'Error getting netwatch info: {exc}') 62 | return [] 63 | 64 | @staticmethod 65 | def _display_table(records): 66 | ''' Display netwatch records in a table 67 | ''' 68 | if not records: 69 | return 70 | 71 | # Sort records by name, then by host 72 | sorted_records = sorted(records, key=lambda x: (x.get('name', ''), x.get('host', ''))) 73 | 74 | # Create output table 75 | output_entry = BaseOutputProcessor.OutputNetwatchEntry 76 | output_table = BaseOutputProcessor.output_table(output_entry) 77 | 78 | # Add records to table 79 | for record in sorted_records: 80 | # Filter record to only include fields we need for output in the new order 81 | filtered_record = { 82 | 'name': record.get('name', ''), 83 | 'host': record.get('host', ''), 84 | 'comment': record.get('comment', ''), 85 | 'status': record.get('status', ''), 86 | 'type': record.get('type', ''), 87 | 'since': record.get('since', ''), 88 | 'timeout': record.get('timeout', ''), 89 | 'interval': record.get('interval', '') 90 | } 91 | output_table.add_row(output_entry(**filtered_record)) 92 | 93 | # Print table with title 94 | print("Netwatch Entries:") 95 | print(output_table.draw()) 96 | 97 | # Print summary 98 | total_entries = len(sorted_records) 99 | up_count = len([r for r in sorted_records if r.get('status', '').lower() == 'up']) 100 | down_count = total_entries - up_count 101 | 102 | print(f"Total entries: {total_entries}") 103 | print(f"Up: {up_count}") 104 | print(f"Down: {down_count}") -------------------------------------------------------------------------------- /mktxp/collector/bfd_collector.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | 15 | from mktxp.collector.base_collector import BaseCollector 16 | from mktxp.flow.processor.output import BaseOutputProcessor 17 | from mktxp.datasource.bfd_ds import BFDMetricsDataSource 18 | 19 | 20 | class BFDCollector(BaseCollector): 21 | """ 22 | Bidirectional Forwarding Detection (BFD) collector 23 | """ 24 | @staticmethod 25 | def collect(router_entry): 26 | if not router_entry.config_entry.bfd: 27 | return 28 | 29 | translation_table = { 30 | "actual_tx_interval": lambda value: BaseOutputProcessor.parse_timedelta_milliseconds(value, ms_span=True) if value else "0", 31 | "desired_tx_interval": lambda value: BaseOutputProcessor.parse_timedelta_milliseconds(value, ms_span=True) if value else "0", 32 | "hold_time": lambda value: BaseOutputProcessor.parse_timedelta_milliseconds(value, ms_span=True) if value else "0", 33 | "up": lambda value: "1" if value == "true" else "0", 34 | "uptime": lambda value: BaseOutputProcessor.parse_timedelta_milliseconds(value) if value else "0", 35 | } 36 | 37 | default_labels = ["local_address", "remote_address"] 38 | metric_records = BFDMetricsDataSource.metric_records( 39 | router_entry, 40 | translation_table=translation_table, 41 | ) 42 | 43 | if not metric_records: 44 | return 45 | 46 | yield BaseCollector.gauge_collector( 47 | "bfd_multiplier", 48 | "The multiplier", 49 | metric_records, 50 | metric_key="multiplier", 51 | metric_labels=default_labels 52 | ) 53 | yield BaseCollector.gauge_collector( 54 | "bfd_hold_time", 55 | "The hold time in milliseconds", 56 | metric_records, 57 | metric_key="hold_time", 58 | metric_labels=default_labels 59 | ) 60 | yield BaseCollector.counter_collector( 61 | "bfd_rx_packet", 62 | "BFD control packets received", 63 | metric_records, 64 | metric_key="packets_rx", 65 | metric_labels=default_labels, 66 | ) 67 | yield BaseCollector.counter_collector( 68 | "bfd_state_change", 69 | "Number of time the state changed", 70 | metric_records, 71 | metric_key="state_changes", 72 | metric_labels=default_labels, 73 | ) 74 | yield BaseCollector.gauge_collector( 75 | "bfd_tx_interval", 76 | "The actual transmit interval", 77 | metric_records, 78 | metric_key="actual_tx_interval", 79 | metric_labels=default_labels 80 | ) 81 | yield BaseCollector.gauge_collector( 82 | "bfd_tx_interval_desired", 83 | "Desired transmit interval is the highes value from local tx interval and remote minimum rx interval", 84 | metric_records, 85 | metric_key="desired_tx_interval", 86 | metric_labels=default_labels 87 | ) 88 | yield BaseCollector.counter_collector( 89 | "bfd_tx_packet", 90 | "BFD control packets transmitted", 91 | metric_records, 92 | metric_key="packets_tx", 93 | metric_labels=default_labels, 94 | ) 95 | yield BaseCollector.gauge_collector( 96 | "bfd_up", 97 | "BFD is up", 98 | metric_records, 99 | metric_key="up", 100 | metric_labels=default_labels 101 | ) 102 | yield BaseCollector.gauge_collector( 103 | "bfd_uptime", 104 | "BFD uptime in milliseconds", 105 | metric_records, 106 | metric_key="uptime", 107 | metric_labels=default_labels 108 | ) 109 | -------------------------------------------------------------------------------- /mktxp/collector/lte_collector.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | from mktxp.collector.base_collector import BaseCollector 15 | from mktxp.datasource.interface_ds import InterfaceMonitorMetricsDataSource 16 | from mktxp.utils.utils import parse_mkt_uptime 17 | 18 | 19 | class LTECollector(BaseCollector): 20 | ''' LTE Metrics collector 21 | ''' 22 | @staticmethod 23 | def collect(router_entry): 24 | if not router_entry.config_entry.lte: 25 | return 26 | 27 | monitor_labels = ['pin_status', 'registration_status', 'functionality', 'current_operator', 'access_technology', 'session_uptime', 'subscriber_number', 'rsrp', 'rsrq', 'nr_rsrp', 'nr_rsrq', 'nr_sinr'] 28 | translation_table = { 29 | 'pin_status': lambda value: '1' if value=='ok' else '0', 30 | 'registration_status': lambda value: '1' if value=='registered' else '0', 31 | 'session_uptime': lambda value: parse_mkt_uptime(value) 32 | } 33 | monitor_records = InterfaceMonitorMetricsDataSource.metric_records(router_entry, 34 | translation_table=translation_table, 35 | kind = 'lte', 36 | running_only = False) 37 | if monitor_records: 38 | yield BaseCollector.gauge_collector('lte_pin_status', 'Pin status', monitor_records, 'pin_status', []) 39 | yield BaseCollector.gauge_collector('lte_registration_status', 'Registration status', monitor_records, 'registration_status', []) 40 | yield BaseCollector.info_collector('lte_current_operator', 'LTE operator', monitor_records, ['current_operator', 'access_technology', 'functionality', 'subscriber_number']) 41 | yield BaseCollector.gauge_collector('lte_session_uptime', 'LTE session uptime', monitor_records, 'session_uptime', []) 42 | 43 | # specific labels 44 | rsrp_records = [monitor_record for monitor_record in monitor_records if monitor_record.get('rsrp')] 45 | rsrq_records = [monitor_record for monitor_record in monitor_records if monitor_record.get('rsrq')] 46 | sinr_records = [monitor_record for monitor_record in monitor_records if monitor_record.get('sinr')] 47 | rssi_records = [monitor_record for monitor_record in monitor_records if monitor_record.get('rssi')] 48 | 49 | if rsrp_records: 50 | yield BaseCollector.gauge_collector('lte_rsrp', 'LTE Reference Signal Received Qualityrsrp value', rsrp_records, 'rsrp', []) 51 | if rsrq_records: 52 | yield BaseCollector.gauge_collector('lte_rsrq', 'LTE Referenz Signal Received Power value', rsrq_records, 'rsrq', []) 53 | if sinr_records: 54 | yield BaseCollector.gauge_collector('lte_sinr', 'LTE signal to noise ratio value', sinr_records, 'sinr', []) 55 | if rssi_records: 56 | yield BaseCollector.gauge_collector('lte_rssi', 'Received Signal Strength Indicator', rssi_records, 'rssi', []) 57 | 58 | # 5G NR signal metrics 59 | nr_rsrp_records = [monitor_record for monitor_record in monitor_records if monitor_record.get('nr_rsrp') ] 60 | nr_rsrq_records = [monitor_record for monitor_record in monitor_records if monitor_record.get('nr_rsrq') ] 61 | nr_sinr_records = [monitor_record for monitor_record in monitor_records if monitor_record.get('nr_sinr') ] 62 | 63 | if nr_rsrp_records: 64 | yield BaseCollector.gauge_collector('lte_nr_rsrp', '5G NR Reference Signal Received Power value', nr_rsrp_records, 'nr_rsrp', []) 65 | if nr_rsrq_records: 66 | yield BaseCollector.gauge_collector('lte_nr_rsrq', '5G NR Reference Signal Received Quality value', nr_rsrq_records, 'nr_rsrq', []) 67 | if nr_sinr_records: 68 | yield BaseCollector.gauge_collector('lte_nr_sinr', '5G NR signal to noise ratio value', nr_sinr_records, 'nr_sinr', []) 69 | -------------------------------------------------------------------------------- /mktxp/datasource/base_ds.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | from mktxp.cli.config.config import MKTXPConfigKeys 15 | from mktxp.cli.config.config import config_handler 16 | 17 | class BaseDSProcessor: 18 | ''' Base Metrics DataSource processing 19 | ''' 20 | 21 | @staticmethod 22 | def trimmed_records(router_entry, *, router_records = None, metric_labels = None, add_router_id = True, translation_table = None, translate_if_no_value = True): 23 | metric_labels = metric_labels or [] 24 | router_records = router_records or [] 25 | translation_table = translation_table or {} 26 | 27 | if len(metric_labels) == 0 and len(router_records) > 0: 28 | metric_labels = [BaseDSProcessor._normalise_keys(key) for key in router_records[0].keys()] 29 | metric_labels = set(metric_labels) 30 | 31 | labeled_records = [] 32 | for router_record in router_records: 33 | translated_record = {BaseDSProcessor._normalise_keys(key): value for (key, value) in router_record.items() if BaseDSProcessor._normalise_keys(key) in metric_labels} 34 | 35 | if add_router_id: 36 | translated_record.update(router_entry.router_id) 37 | 38 | if router_entry.config_entry.custom_labels: 39 | custom_labels = BaseDSProcessor._parse_custom_labels(router_entry.config_entry.custom_labels, router_entry) 40 | if custom_labels: 41 | translated_record[MKTXPConfigKeys.CUSTOM_LABELS_METADATA_ID] = custom_labels 42 | 43 | # translate fields if needed 44 | for key, func in translation_table.items(): 45 | if translate_if_no_value or translated_record.get(key) is not None: 46 | translated_record[key] = func(translated_record.get(key)) 47 | labeled_records.append(translated_record) 48 | return labeled_records 49 | 50 | @staticmethod 51 | def count_records(router_entry, *, api_path, api_query=None): 52 | api_query = api_query or {} 53 | try: 54 | resource = router_entry.api_connection.router_api().get_resource(api_path) 55 | response = resource.call('print', {'count-only': ''}, api_query).done_message 56 | if response: 57 | return int(response.get('ret', 0)) 58 | return 0 59 | except Exception as exc: 60 | print(f'Error getting record count for {api_path} from router {router_entry.router_name}@{router_entry.config_entry.hostname}: {exc}') 61 | return None 62 | 63 | @staticmethod 64 | def _normalise_keys(key): 65 | chars = ".-" 66 | for chr in chars: 67 | if chr in key: 68 | key = key.replace(chr, "_") 69 | return key 70 | 71 | @staticmethod 72 | def _parse_custom_labels(custom_labels, router_entry): 73 | if not custom_labels or custom_labels == 'None': 74 | return {} 75 | 76 | labels_list = [] 77 | if isinstance(custom_labels, str): 78 | labels_list = custom_labels.split(',') 79 | elif isinstance(custom_labels, (list, tuple)): 80 | labels_list = [str(item) for item in custom_labels] 81 | else: 82 | return {} 83 | 84 | labels_dict = {} 85 | for item in labels_list: 86 | try: 87 | if isinstance(item, str) and (':' in item or '=' in item): 88 | key, value = item.split(':', 1) if ':' in item else item.split('=', 1) 89 | labels_dict[key.strip()] = value.strip() 90 | else: 91 | if config_handler.system_entry.verbose_mode: 92 | print(f"Warning: Configuration for {router_entry.router_name} contains a malformed custom label '{item}'. It should be in 'key:value' or 'key=value' format. Ignoring.") 93 | except Exception as e: 94 | print(f"Warning: Could not parse custom label '{item} for {router_entry.router_name}'. Error: {e}. Ignoring.") 95 | return labels_dict 96 | -------------------------------------------------------------------------------- /mktxp/collector/ipsec_collector.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | 15 | from mktxp.collector.base_collector import BaseCollector 16 | from mktxp.flow.processor.output import BaseOutputProcessor 17 | from mktxp.datasource.ipsec_ds import IPSecMetricsDataSource 18 | 19 | 20 | class IPSecCollector(BaseCollector): 21 | '''IPSec collector''' 22 | 23 | @staticmethod 24 | def collect(router_entry): 25 | if not router_entry.config_entry.ipsec: 26 | return 27 | 28 | ipsec_labels = ['local_address', 'remote_address', 'name', 'last_seen', 'uptime', 'ph2_total', 'responder', 29 | 'natt_peer', 'rx_bytes', 'rx_packets', 'tx_bytes', 'tx_packets', 'state'] 30 | translation_table = { 31 | 'responder': lambda value: '1' if value == 'true' else '0', 32 | 'natt_peer': lambda value: '1' if value == 'true' else '0', 33 | 'last_seen': lambda value: BaseOutputProcessor.parse_timedelta_milliseconds(value) if value else '0', 34 | 'uptime': lambda value: BaseOutputProcessor.parse_timedelta_milliseconds(value) if value else '0' 35 | } 36 | ipsec_records = IPSecMetricsDataSource.metric_records(router_entry, metric_labels=ipsec_labels, 37 | translation_table=translation_table) 38 | 39 | if ipsec_records: 40 | ipsec_info_labels = ['local_address', 'name', 'remote_address', 'state'] 41 | yield BaseCollector.info_collector('ipsec_peer_state', 42 | 'State of negotiation with the peer.', 43 | ipsec_records, ipsec_info_labels) 44 | 45 | ipsec_value_labels = ['local_address', 'name', 'remote_address'] 46 | yield BaseCollector.counter_collector('ipsec_peer_rx_byte', 47 | 'The total amount of bytes received from this peer.', 48 | ipsec_records, 'rx_bytes', ipsec_value_labels) 49 | 50 | yield BaseCollector.counter_collector('ipsec_peer_tx_byte', 51 | 'The total amount of bytes transmitted to this peer.', 52 | ipsec_records, 'tx_bytes', ipsec_value_labels) 53 | 54 | yield BaseCollector.counter_collector('ipsec_peer_rx_packet', 55 | 'The total amount of packets received from this peer.', 56 | ipsec_records, 'rx_packets', ipsec_value_labels) 57 | 58 | yield BaseCollector.counter_collector('ipsec_peer_tx_packet', 59 | 'The total amount of packets transmitted to this peer.', 60 | ipsec_records, 'tx_packets', ipsec_value_labels) 61 | 62 | yield BaseCollector.gauge_collector('ipsec_peer_security_association', 63 | 'The total amount of active IPsec security associations.', 64 | ipsec_records, 'ph2_total', ipsec_value_labels) 65 | 66 | yield BaseCollector.gauge_collector('ipsec_peer_last_seen', 67 | 'Duration since the last message received by this peer.', 68 | ipsec_records, 'last_seen', ipsec_value_labels) 69 | 70 | yield BaseCollector.gauge_collector('ipsec_peer_uptime', 71 | 'How long peer is in an established state.', 72 | ipsec_records, 'uptime', ipsec_value_labels) 73 | 74 | yield BaseCollector.gauge_collector('ipsec_peer_responder', 75 | 'Whether the connection is initiated by a remote peer.', 76 | ipsec_records, 'responder', ipsec_value_labels) 77 | 78 | yield BaseCollector.gauge_collector('ipsec_peer_natt_enabled', 79 | 'Whether NAT-T is used for this peer.', 80 | ipsec_records, 'natt_peer', ipsec_value_labels) 81 | -------------------------------------------------------------------------------- /mktxp/collector/wlan_collector.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | 15 | from mktxp.flow.processor.output import BaseOutputProcessor 16 | from mktxp.collector.base_collector import BaseCollector 17 | from mktxp.datasource.wireless_ds import WirelessMetricsDataSource 18 | from mktxp.datasource.interface_ds import InterfaceMonitorMetricsDataSource 19 | 20 | 21 | class WLANCollector(BaseCollector): 22 | ''' Wireless Metrics collector 23 | ''' 24 | @staticmethod 25 | def collect(router_entry): 26 | if not router_entry.config_entry.wireless: 27 | return 28 | 29 | monitor_labels = ['channel', 'noise_floor', 'overall_tx_ccq', 'registered_clients', 'registered_peers'] 30 | monitor_records = InterfaceMonitorMetricsDataSource.metric_records(router_entry, metric_labels = monitor_labels, kind = WirelessMetricsDataSource.wireless_package(router_entry)) 31 | if monitor_records: 32 | # sanitize records for relevant labels 33 | noise_floor_records = [monitor_record for monitor_record in monitor_records if monitor_record.get('noise_floor')] 34 | tx_ccq_records = [monitor_record for monitor_record in monitor_records if monitor_record.get('overall_tx_ccq')] 35 | registered_clients_records = [monitor_record for monitor_record in monitor_records if monitor_record.get('registered_clients')] 36 | 37 | if noise_floor_records: 38 | noise_floor_metrics = BaseCollector.gauge_collector('wlan_noise_floor', 'Noise floor threshold', noise_floor_records, 'noise_floor', ['channel']) 39 | yield noise_floor_metrics 40 | 41 | if tx_ccq_records: 42 | overall_tx_ccq_metrics = BaseCollector.gauge_collector('wlan_overall_tx_ccq', 'Client Connection Quality for transmitting', tx_ccq_records, 'overall_tx_ccq', ['channel']) 43 | yield overall_tx_ccq_metrics 44 | 45 | if registered_clients_records: 46 | registered_clients_metrics = BaseCollector.gauge_collector('wlan_registered_clients', 'Number of registered clients', registered_clients_records, 'registered_clients', ['channel']) 47 | yield registered_clients_metrics 48 | 49 | # the client info metrics 50 | if router_entry.config_entry.wireless_clients: 51 | registration_labels = ['interface', 'ssid', 'mac_address', 'tx_rate', 'rx_rate', 'uptime', 'bytes', 'signal_to_noise', 'tx_ccq', 'signal_strength', 'signal'] 52 | registration_records = WirelessMetricsDataSource.metric_records(router_entry, metric_labels = registration_labels) 53 | if registration_records: 54 | for registration_record in registration_records: 55 | BaseOutputProcessor.augment_record(router_entry, registration_record) 56 | 57 | tx_byte_metrics = BaseCollector.counter_collector('wlan_clients_tx_bytes', 'Number of sent packet bytes', registration_records, 'tx_bytes', ['dhcp_name', 'mac_address']) 58 | yield tx_byte_metrics 59 | 60 | rx_byte_metrics = BaseCollector.counter_collector('wlan_clients_rx_bytes', 'Number of received packet bytes', registration_records, 'rx_bytes', ['dhcp_name', 'mac_address']) 61 | yield rx_byte_metrics 62 | 63 | signal_strength_metrics = BaseCollector.gauge_collector('wlan_clients_signal_strength', 'Average strength of the client signal recevied by AP', registration_records, 'signal_strength', ['dhcp_name', 'mac_address']) 64 | yield signal_strength_metrics 65 | 66 | signal_to_noise_metrics = BaseCollector.gauge_collector('wlan_clients_signal_to_noise', 'Client devices signal to noise ratio', registration_records, 'signal_to_noise', ['dhcp_name', 'mac_address']) 67 | yield signal_to_noise_metrics 68 | 69 | tx_ccq_metrics = BaseCollector.gauge_collector('wlan_clients_tx_ccq', 'Client Connection Quality (CCQ) for transmit', registration_records, 'tx_ccq', ['dhcp_name', 'mac_address']) 70 | yield tx_ccq_metrics 71 | 72 | registration_metrics = BaseCollector.info_collector('wlan_clients_devices', 'Client devices info', 73 | registration_records, ['dhcp_name', 'dhcp_address', 'rx_signal', 'ssid', 'tx_rate', 'rx_rate', 'interface', 'mac_address', 'uptime']) 74 | yield registration_metrics 75 | 76 | 77 | -------------------------------------------------------------------------------- /tests/flow/test_router_entry.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | import pytest 15 | from unittest.mock import MagicMock, patch 16 | from mktxp.flow.router_entry import RouterEntry 17 | from mktxp.flow.router_connection import RouterAPIConnection 18 | 19 | @pytest.fixture 20 | def mock_api_connection(): 21 | """Fixture to create a mock RouterAPIConnection""" 22 | connection = MagicMock(spec=RouterAPIConnection) 23 | connection.is_connected.return_value = False 24 | return connection 25 | 26 | @pytest.fixture(params=[(True, True), (True, False), (False, True), (False, False)]) 27 | def router_entry(request, tmpdir, mock_api_connection): 28 | """Fixture to create a RouterEntry with different persistence settings.""" 29 | 30 | persistent_pool, persistent_dhcp = request.param 31 | 32 | # Create temporary config files 33 | mktxp_conf = tmpdir.join("mktxp.conf") 34 | mktxp_conf.write(""" 35 | [test_router] 36 | hostname = localhost 37 | """) 38 | _mktxp_conf = tmpdir.join("_mktxp.conf") 39 | _mktxp_conf.write(f""" 40 | [MKTXP] 41 | persistent_router_connection_pool = {persistent_pool} 42 | persistent_dhcp_cache = {persistent_dhcp} 43 | compact_default_conf_values = False 44 | verbose_mode = False 45 | """) 46 | 47 | with patch('mktxp.flow.router_entry.RouterAPIConnection', return_value=mock_api_connection): 48 | from mktxp.cli.config.config import config_handler, CustomConfig 49 | config_handler(os_config=CustomConfig(str(tmpdir))) 50 | 51 | entry = RouterEntry('test_router') 52 | entry.persistent_pool = persistent_pool 53 | entry.persistent_dhcp_cache = persistent_dhcp 54 | return entry 55 | 56 | @pytest.mark.parametrize( 57 | "initial_connected_state, connect_succeeds, expected_ready_state, expect_connect_call", 58 | [ 59 | (True, None, True, False), 60 | (False, True, True, True), 61 | (False, False, False, True), 62 | ] 63 | ) 64 | def test_is_ready_logic(initial_connected_state, connect_succeeds, expected_ready_state, expect_connect_call, router_entry, mock_api_connection): 65 | # Arrange 66 | mock_api_connection.is_connected.return_value = initial_connected_state 67 | if connect_succeeds is not None: 68 | def connect_side_effect(): 69 | mock_api_connection.is_connected.return_value = connect_succeeds 70 | mock_api_connection.connect.side_effect = connect_side_effect 71 | 72 | # Act 73 | ready = router_entry.is_ready() 74 | 75 | # Assert 76 | assert ready is expected_ready_state 77 | if expect_connect_call: 78 | mock_api_connection.connect.assert_called_once() 79 | else: 80 | mock_api_connection.connect.assert_not_called() 81 | 82 | def test_is_done_disconnects(router_entry, mock_api_connection): 83 | # Arrange 84 | # Setup child entries to test their disconnection as well 85 | dhcp_connection = MagicMock(spec=RouterAPIConnection) 86 | capsman_connection = MagicMock(spec=RouterAPIConnection) 87 | router_entry.dhcp_entry = MagicMock() 88 | router_entry.dhcp_entry.api_connection = dhcp_connection 89 | router_entry.capsman_entry = MagicMock() 90 | router_entry.capsman_entry.api_connection = capsman_connection 91 | 92 | # Act 93 | router_entry.is_done() 94 | 95 | # Assert 96 | if not router_entry.persistent_pool: 97 | mock_api_connection.disconnect.assert_called_once() 98 | dhcp_connection.disconnect.assert_called_once() 99 | capsman_connection.disconnect.assert_called_once() 100 | else: 101 | mock_api_connection.disconnect.assert_not_called() 102 | dhcp_connection.disconnect.assert_not_called() 103 | capsman_connection.disconnect.assert_not_called() 104 | 105 | def test_is_done_dhcp_cache(router_entry): 106 | """ 107 | Tests that the DHCP cache is cleared or not based on the persistent_dhcp_cache setting. 108 | """ 109 | # Arrange 110 | router_entry.dhcp_records = [{'mac_address': '00:00:00:00:00:01', 'address': '1.1.1.1'}] 111 | assert router_entry._dhcp_records != {} 112 | 113 | # Act 114 | router_entry.is_done() 115 | 116 | # Assert 117 | if not router_entry.persistent_dhcp_cache: 118 | assert router_entry._dhcp_records == {} 119 | else: 120 | assert router_entry._dhcp_records != {} 121 | -------------------------------------------------------------------------------- /mktxp/collector/switch_collector.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | 15 | from mktxp.collector.base_collector import BaseCollector 16 | from mktxp.datasource.switch_ds import SwitchPortMetricsDataSource 17 | 18 | 19 | class SwitchPortCollector(BaseCollector): 20 | '''Switch Port collector''' 21 | @staticmethod 22 | def collect(router_entry): 23 | if not router_entry.config_entry.switch_port: 24 | return 25 | 26 | switch_port_labels = ['name', 'driver_rx_byte', 'driver_rx_packet', 'driver_tx_byte', 'driver_tx_packet', 27 | 'rx_bytes', 'rx_broadcast', 'rx_pause', 'rx_multicast', 'rx_fcs_error', 'rx_align_error', 'rx_fragment', 'rx_overflow', 28 | 'tx_bytes', 'tx_broadcast', 'tx_pause', 'tx_multicast', 'tx_underrun', 'tx_collision', 'tx_deferred'] 29 | 30 | switch_port_records = SwitchPortMetricsDataSource.metric_records(router_entry, metric_labels = switch_port_labels) 31 | if switch_port_records: 32 | for record in switch_port_records: 33 | for field in switch_port_labels[1:]: 34 | if field in record and ',' in record[field]: # https://help.mikrotik.com/docs/display/ROS/Switch+Chip+Features#SwitchChipFeatures-Statistics 35 | # Sum each CPU lane for the total 36 | record[field] = str(sum([int(count) for count in record[field].split(",")])) 37 | yield BaseCollector.counter_collector('switch_driver_rx_byte', 'Total count of received bytes', switch_port_records, 'driver_rx_byte', ['name']) 38 | yield BaseCollector.counter_collector('switch_driver_rx_packet', 'Total count of received packets', switch_port_records, 'driver_rx_packet', ['name']) 39 | yield BaseCollector.counter_collector('switch_driver_tx_byte', 'Total count of transmitted bytes', switch_port_records, 'driver_tx_byte', ['name']) 40 | yield BaseCollector.counter_collector('switch_driver_tx_packet', 'Total count of transmitted packets', switch_port_records, 'driver_tx_packet', ['name']) 41 | yield BaseCollector.counter_collector('switch_rx_bytes', 'Total count of received bytes', switch_port_records, 'rx_bytes', ['name']) 42 | yield BaseCollector.counter_collector('switch_rx_broadcast', 'Total count of received broadcast frames', switch_port_records, 'rx_broadcast', ['name']) 43 | yield BaseCollector.counter_collector('switch_rx_pause', 'Total count of received pause frames', switch_port_records, 'rx_pause', ['name']) 44 | yield BaseCollector.counter_collector('switch_rx_multicast', 'Total count of received multicast frames', switch_port_records, 'rx_multicast', ['name']) 45 | yield BaseCollector.counter_collector('switch_rx_fcs_error', 'Total count of received frames with incorrect checksum', switch_port_records, 'rx_fcs_error', ['name']) 46 | yield BaseCollector.counter_collector('switch_rx_align_error', 'Total count of received align error event', switch_port_records, 'rx_align_error', ['name']) 47 | yield BaseCollector.counter_collector('switch_rx_fragment', 'Total count of received fragmented frames', switch_port_records, 'rx_fragment', ['name']) 48 | yield BaseCollector.counter_collector('switch_rx_overflow', 'Total count of received overflowed frames', switch_port_records, 'rx_overflow', ['name']) 49 | yield BaseCollector.counter_collector('switch_tx_bytes', 'Total count of transmitted bytes', switch_port_records, 'tx_bytes', ['name']) 50 | yield BaseCollector.counter_collector('switch_tx_broadcast', 'Total count of transmitted broadcast frames', switch_port_records, 'tx_broadcast', ['name']) 51 | yield BaseCollector.counter_collector('switch_tx_pause', 'Total count of transmitted pause frames', switch_port_records, 'tx_pause', ['name']) 52 | yield BaseCollector.counter_collector('switch_tx_multicast', 'Total count of transmitted multicast frames', switch_port_records, 'tx_multicast', ['name']) 53 | yield BaseCollector.counter_collector('switch_tx_underrun', 'Total count of transmitted underrun packets', switch_port_records, 'tx_underrun', ['name']) 54 | yield BaseCollector.counter_collector('switch_tx_collision', 'Total count of transmitted frames that made collisions', switch_port_records, 'tx_collision', ['name']) 55 | yield BaseCollector.counter_collector('switch_tx_deferred', 'Total count of transmitted frames that were delayed on its first transmit attempt', switch_port_records, 'tx_deferred', ['name']) 56 | -------------------------------------------------------------------------------- /mktxp/cli/dispatch.py: -------------------------------------------------------------------------------- 1 | #!.usr/bin/env python 2 | # coding=utf8 3 | ## Copyright (c) 2020 Arseniy Kuznetsov 4 | ## 5 | ## This program is free software; you can redistribute it and/or 6 | ## modify it under the terms of the GNU General Public License 7 | ## as published by the Free Software Foundation; either version 2 8 | ## of the License, or (at your option) any later version. 9 | ## 10 | ## This program is distributed in the hope that it will be useful, 11 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | ## GNU General Public License for more details. 14 | 15 | 16 | import subprocess 17 | import shlex 18 | from mktxp.cli.config.config import config_handler 19 | from mktxp.cli.options import MKTXPOptionsParser, MKTXPCommands 20 | from mktxp.flow.processor.base_proc import ExportProcessor, OutputProcessor 21 | 22 | 23 | class MKTXPDispatcher: 24 | ''' Base MKTXP Commands Dispatcher 25 | ''' 26 | def __init__(self): 27 | self.option_parser = MKTXPOptionsParser() 28 | 29 | # Dispatcher 30 | def dispatch(self): 31 | args = self.option_parser.parse_options() 32 | 33 | if args['sub_cmd'] == MKTXPCommands.INFO: 34 | self.print_info() 35 | 36 | elif args['sub_cmd'] == MKTXPCommands.SHOW: 37 | self.show_entries(args) 38 | 39 | elif args['sub_cmd'] == MKTXPCommands.EXPORT: 40 | self.start_export(args) 41 | 42 | elif args['sub_cmd'] == MKTXPCommands.PRINT: 43 | self.print(args) 44 | 45 | elif args['sub_cmd'] == MKTXPCommands.EDIT: 46 | self.edit_entry(args) 47 | 48 | else: 49 | # nothing to dispatch 50 | return False 51 | 52 | return True 53 | 54 | # Dispatched methods 55 | def print_info(self): 56 | ''' Prints MKTXP general info 57 | ''' 58 | print(f'{self.option_parser.script_name}: {self.option_parser.description}') 59 | 60 | def show_entries(self, args): 61 | if args['config']: 62 | print(f'MKTXP data config: {config_handler.usr_conf_data_path}') 63 | print(f'MKTXP internal config: {config_handler.mktxp_conf_path}') 64 | else: 65 | for entryname in config_handler.registered_entries(): 66 | if args['entry_name'] and entryname != args['entry_name']: 67 | continue 68 | entry = config_handler.config_entry(entryname) 69 | print(f'[{entryname}]') 70 | divider_fields = set(['username', 'use_ssl', 'dhcp']) 71 | for field in entry._fields: 72 | if field == 'password': 73 | print(f' {field}: {"*" * len(entry.password)}') 74 | else: 75 | if field in divider_fields: 76 | print() 77 | print(f' {field}: {getattr(entry, field)}') 78 | print('\n') 79 | 80 | def edit_entry(self, args): 81 | editor = args['editor'] 82 | if not editor: 83 | # Try to detect editor if not provided 84 | editor = self.option_parser._system_editor() 85 | 86 | if not editor: 87 | print(f'No editor found to edit configuration files.') 88 | print(f'Please set the EDITOR environment variable or specify an editor with --editor') 89 | return 90 | 91 | # Parse editor command to handle arguments (e.g., "subl -w" or "'path with spaces' -w") 92 | editor_cmd = shlex.split(editor) 93 | 94 | if args['internal']: 95 | subprocess.check_call(editor_cmd + [config_handler.mktxp_conf_path]) 96 | else: 97 | subprocess.check_call(editor_cmd + [config_handler.usr_conf_data_path]) 98 | 99 | def start_export(self, args): 100 | ExportProcessor.start() 101 | 102 | def print(self, args): 103 | if args['wifi_clients']: 104 | OutputProcessor.wifi_clients(args['entry_name']) 105 | 106 | elif args['capsman_clients']: 107 | OutputProcessor.capsman_clients(args['entry_name']) 108 | 109 | elif args['dhcp_clients']: 110 | OutputProcessor.dhcp_clients(args['entry_name']) 111 | 112 | elif args['conn_stats']: 113 | OutputProcessor.conn_stats(args['entry_name']) 114 | 115 | elif args['kid_control']: 116 | OutputProcessor.kid_control(args['entry_name']) 117 | 118 | elif args['address_lists']: 119 | OutputProcessor.address_lists(args['entry_name'], args['address_lists']) 120 | 121 | elif args['netwatch']: 122 | OutputProcessor.netwatch(args['entry_name']) 123 | 124 | else: 125 | print("Select metric option(s) to print out, or run 'mktxp print -h' to find out more") 126 | 127 | def main(): 128 | MKTXPDispatcher().dispatch() 129 | 130 | if __name__ == '__main__': 131 | main() 132 | 133 | -------------------------------------------------------------------------------- /mktxp/collector/capsman_collector.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | 15 | from mktxp.cli.config.config import MKTXPConfigKeys 16 | from mktxp.flow.processor.output import BaseOutputProcessor 17 | from mktxp.collector.base_collector import BaseCollector 18 | from mktxp.datasource.capsman_ds import CapsmanCapsMetricsDataSource, CapsmanRegistrationsMetricsDataSource, CapsmanInterfacesDatasource 19 | from mktxp.datasource.wireless_ds import WirelessMetricsDataSource 20 | 21 | 22 | class CapsmanCollector(BaseCollector): 23 | ''' CAPsMAN Metrics collector 24 | ''' 25 | @staticmethod 26 | def collect(router_entry): 27 | if not router_entry.config_entry.capsman: 28 | return 29 | 30 | remote_caps_labels = ['identity', 'version', 'base_mac', 'board', 'base_mac'] 31 | remote_caps_records = CapsmanCapsMetricsDataSource.metric_records(router_entry, metric_labels = remote_caps_labels) 32 | if remote_caps_records: 33 | remote_caps_metrics = BaseCollector.info_collector('capsman_remote_caps', 'CAPsMAN remote caps', remote_caps_records, remote_caps_labels) 34 | yield remote_caps_metrics 35 | 36 | registration_labels = ['interface', 'ssid', 'mac_address', 'tx_rate', 'rx_rate', 'rx_signal', 'signal', 'uptime', 'bytes'] 37 | registration_records = CapsmanRegistrationsMetricsDataSource.metric_records(router_entry, metric_labels = registration_labels) 38 | if registration_records: 39 | # calculate number of registrations per interface 40 | registration_per_interface = {} 41 | for registration_record in registration_records: 42 | registration_per_interface[registration_record['interface']] = registration_per_interface.get(registration_record['interface'], 0) + 1 43 | # compile registrations-per-interface records 44 | registration_per_interface_records = [{ MKTXPConfigKeys.ROUTERBOARD_NAME: router_entry.router_id[MKTXPConfigKeys.ROUTERBOARD_NAME], 45 | MKTXPConfigKeys.ROUTERBOARD_ADDRESS: router_entry.router_id[MKTXPConfigKeys.ROUTERBOARD_ADDRESS], 46 | 'interface': key, 'count': value} for key, value in registration_per_interface.items()] 47 | # yield registrations-per-interface metrics 48 | registration_per_interface_metrics = BaseCollector.gauge_collector('capsman_registrations_count', 'Number of active registration per CAPsMAN interface', registration_per_interface_records, 'count', ['interface']) 49 | yield registration_per_interface_metrics 50 | 51 | # the client info metrics 52 | if router_entry.config_entry.capsman_clients: 53 | 54 | # translate / trim / augment registration records 55 | for registration_record in registration_records: 56 | BaseOutputProcessor.augment_record(router_entry, registration_record) 57 | 58 | tx_byte_metrics = BaseCollector.counter_collector('capsman_clients_tx_bytes', 'Number of sent packet bytes', registration_records, 'tx_bytes', ['dhcp_name', 'mac_address']) 59 | yield tx_byte_metrics 60 | 61 | rx_byte_metrics = BaseCollector.counter_collector('capsman_clients_rx_bytes', 'Number of received packet bytes', registration_records, 'rx_bytes', ['dhcp_name', 'mac_address']) 62 | yield rx_byte_metrics 63 | 64 | signal_strength_metrics = BaseCollector.gauge_collector('capsman_clients_signal_strength', 'Client devices signal strength', registration_records, 'rx_signal', ['dhcp_name', 'mac_address']) 65 | yield signal_strength_metrics 66 | 67 | registration_metrics = BaseCollector.info_collector('capsman_clients_devices', 'Registered client devices info', 68 | registration_records, ['dhcp_name', 'dhcp_address', 'rx_signal', 'ssid', 'tx_rate', 'rx_rate', 'interface', 'mac_address', 'uptime']) 69 | yield registration_metrics 70 | 71 | 72 | remote_cap_interface_labels = ['name', 'configuration', 'mac_address', 'current_state', 'current_channel', 'current_registered_clients'] 73 | remote_cap_interface_records = CapsmanInterfacesDatasource.metric_records(router_entry, metric_labels = remote_cap_interface_labels) 74 | if remote_cap_interface_records: 75 | remote_caps_metrics = BaseCollector.info_collector('capsman_interfaces', 'CAPsMAN interfaces', remote_cap_interface_records, remote_cap_interface_labels) 76 | yield remote_caps_metrics 77 | 78 | -------------------------------------------------------------------------------- /mktxp/collector/address_list_collector.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | ## Copyright (c) 2020 Arseniy Kuznetsov 3 | ## 4 | ## This program is free software; you can redistribute it and/or 5 | ## modify it under the terms of the GNU General Public License 6 | ## as published by the Free Software Foundation; either version 2 7 | ## of the License, or (at your option) any later version. 8 | ## 9 | ## This program is distributed in the hope that it will be useful, 10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | ## GNU General Public License for more details. 13 | 14 | 15 | from mktxp.collector.base_collector import BaseCollector 16 | from mktxp.flow.processor.output import BaseOutputProcessor 17 | from mktxp.cli.config.config import MKTXPConfigKeys 18 | from mktxp.datasource.address_list_ds import AddressListMetricsDataSource 19 | 20 | 21 | class AddressListCollector(BaseCollector): 22 | '''Address List collector''' 23 | 24 | @staticmethod 25 | def collect(router_entry): 26 | metric_labels = ['list', 'address', 'dynamic', 'timeout', 'disabled', 'comment'] 27 | reduced_metric_labels = [label for label in metric_labels if label != 'timeout'] 28 | translation_table = { 29 | 'dynamic': lambda value: '1' if value == 'true' else '0', 30 | 'disabled': lambda value: '1' if value == 'true' else '0', 31 | 'timeout': lambda value: BaseOutputProcessor.parse_timedelta_milliseconds(value) if value else '0', 32 | 'comment': lambda value: value if value else '' 33 | } 34 | 35 | # IPv4 36 | address_list_names = AddressListCollector._get_list_names(router_entry.config_entry.address_list) 37 | if address_list_names: 38 | yield from AddressListCollector._collect_and_yield_metrics(router_entry, address_list_names, 'ip', metric_labels, reduced_metric_labels, translation_table) 39 | 40 | # IPv6 41 | ipv6_address_list_names = AddressListCollector._get_list_names(router_entry.config_entry.ipv6_address_list) 42 | if ipv6_address_list_names: 43 | yield from AddressListCollector._collect_and_yield_metrics(router_entry, ipv6_address_list_names, 'ipv6', metric_labels, reduced_metric_labels, translation_table) 44 | 45 | @staticmethod 46 | def _collect_and_yield_metrics(router_entry, address_list_names, ip_version, metric_labels, reduced_metric_labels, translation_table): 47 | ipv6_suffix = '_ipv6' if ip_version == 'ipv6' else '' 48 | 49 | # Collect and yield address list entries 50 | records = AddressListMetricsDataSource.metric_records( 51 | router_entry, 52 | address_list_names, 53 | ip_version, 54 | metric_labels=metric_labels, 55 | translation_table=translation_table 56 | ) 57 | if records: 58 | yield BaseCollector.gauge_collector(f'firewall_address_list{ipv6_suffix}', f'Firewall {ip_version.upper()} Address List Entry', 59 | records, 'timeout', reduced_metric_labels) 60 | 61 | # Collect and yield address list counts 62 | counts = AddressListMetricsDataSource.count_metric_records(router_entry, address_list_names, ip_version) 63 | if not counts: 64 | return 65 | 66 | # Counts for all lists 67 | all_lists_records = [] 68 | for count_type, count in counts['all_lists'].items(): 69 | all_lists_records.append({ 70 | 'count_type': count_type, 71 | 'count': count, 72 | **router_entry.router_id 73 | }) 74 | if all_lists_records: 75 | yield BaseCollector.gauge_collector(f'firewall_address_list_all_count{ipv6_suffix}', 76 | f'Total number of addresses in all {ip_version.upper()} address lists', 77 | all_lists_records, 'count', ['count_type']) 78 | 79 | # Counts for selected lists 80 | selected_lists_records = [] 81 | for list_name, list_counts in counts['selected_lists'].items(): 82 | selected_lists_records.append({ 83 | 'list': list_name, 84 | 'count': list_counts['total'], 85 | **router_entry.router_id 86 | }) 87 | if selected_lists_records: 88 | yield BaseCollector.gauge_collector(f'firewall_address_list_selected_count{ipv6_suffix}', 89 | f'Number of addresses in the selected {ip_version.upper()} address list', 90 | selected_lists_records, 'count', ['list']) 91 | 92 | @staticmethod 93 | def _get_list_names(config_value): 94 | if not config_value: 95 | return [] 96 | if isinstance(config_value, str): 97 | if config_value.lower() == 'none': 98 | return [] 99 | return [name.strip() for name in config_value.split(',') if name.strip()] 100 | if isinstance(config_value, list): 101 | return [name for name in config_value if name] 102 | return [] 103 | --------------------------------------------------------------------------------