├── .github └── workflows │ ├── docker-image.yml │ ├── publish-image.yml │ └── tox.yml ├── .gitignore ├── Dockerfile ├── LICENSE.txt ├── README.md ├── activate ├── bandwidth_monitor.sample.env ├── docker-compose.yml ├── grafana └── provisioning │ ├── dashboards │ ├── bandwidth-monitor.json │ └── dashboard.yml │ └── datasources │ └── datasource.yml ├── prometheus.yml ├── pyproject.toml ├── src ├── __init__.py └── mikrotik_bandwidth_monitor_exporter │ ├── __init__.py │ └── exporter.py ├── tests ├── __init__.py └── mikrotik_bandwidth_monitor_exporter_test │ ├── __init__.py │ └── exporter_test.py └── tox.ini /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image CI 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | jobs: 10 | 11 | build: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v3 18 | - name: Build the Docker image 19 | run: docker build . --file Dockerfile --tag ${{ github.repository }}:$(date +%s) 20 | -------------------------------------------------------------------------------- /.github/workflows/publish-image.yml: -------------------------------------------------------------------------------- 1 | name: Publish Docker Image 2 | 3 | on: 4 | [workflow_dispatch] 5 | 6 | env: 7 | REGISTRY: ghcr.io 8 | IMAGE_NAME: ${{ github.repository }} 9 | VERSION: latest 10 | 11 | jobs: 12 | 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v3 20 | - name: Login to the Docker registry 21 | uses: docker/login-action@v3 22 | with: 23 | registry: ${{ env.REGISTRY }} 24 | username: ${{ github.actor }} 25 | password: ${{ secrets.GITHUB_TOKEN }} 26 | - name: Set metadata for Docker 27 | id: meta 28 | uses: docker/metadata-action@v5 29 | with: 30 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 31 | flavor: latest=true 32 | - name: Publish the Docker image 33 | uses: docker/build-push-action@v5 34 | with: 35 | context: . 36 | push: true 37 | tags: ${{ steps.meta.outputs.tags }} 38 | -------------------------------------------------------------------------------- /.github/workflows/tox.yml: -------------------------------------------------------------------------------- 1 | name: Tox tests 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | jobs: 10 | 11 | test: 12 | name: test with ${{ matrix.py }} on ${{ matrix.os }} 13 | runs-on: ${{ matrix.os }} 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | py: 18 | - "3.12" 19 | os: 20 | - ubuntu-latest 21 | 22 | steps: 23 | - name: Checkout repository 24 | uses: actions/checkout@v3 25 | - name: Setup Python for test ${{ matrix.py }} 26 | uses: actions/setup-python@v4 27 | with: 28 | python-version: ${{ matrix.py }} 29 | - name: Install tox 30 | run: python -m pip install tox-gh>=1.2 31 | - name: Setup test suite 32 | run: tox -vv --notest 33 | - name: Run test suite 34 | run: tox --skip-pkg-install 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/venv,python 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=venv,python 3 | 4 | ### Python ### 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | *.py,cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | cover/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | local_settings.py 65 | db.sqlite3 66 | db.sqlite3-journal 67 | 68 | # Flask stuff: 69 | instance/ 70 | .webassets-cache 71 | 72 | # Scrapy stuff: 73 | .scrapy 74 | 75 | # Sphinx documentation 76 | docs/_build/ 77 | 78 | # PyBuilder 79 | .pybuilder/ 80 | target/ 81 | 82 | # Jupyter Notebook 83 | .ipynb_checkpoints 84 | 85 | # IPython 86 | profile_default/ 87 | ipython_config.py 88 | 89 | # pyenv 90 | # For a library or package, you might want to ignore these files since the code is 91 | # intended to run in multiple environments; otherwise, check them in: 92 | # .python-version 93 | 94 | # pipenv 95 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 96 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 97 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 98 | # install all needed dependencies. 99 | #Pipfile.lock 100 | 101 | # poetry 102 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 103 | # This is especially recommended for binary packages to ensure reproducibility, and is more 104 | # commonly ignored for libraries. 105 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 106 | #poetry.lock 107 | 108 | # pdm 109 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 110 | #pdm.lock 111 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 112 | # in version control. 113 | # https://pdm.fming.dev/#use-with-ide 114 | .pdm.toml 115 | 116 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 117 | __pypackages__/ 118 | 119 | # Celery stuff 120 | celerybeat-schedule 121 | celerybeat.pid 122 | 123 | # SageMath parsed files 124 | *.sage.py 125 | 126 | # Environments 127 | .env 128 | .venv 129 | env/ 130 | venv/ 131 | ENV/ 132 | env.bak/ 133 | venv.bak/ 134 | 135 | # Spyder project settings 136 | .spyderproject 137 | .spyproject 138 | 139 | # Rope project settings 140 | .ropeproject 141 | 142 | # mkdocs documentation 143 | /site 144 | 145 | # mypy 146 | .mypy_cache/ 147 | .dmypy.json 148 | dmypy.json 149 | 150 | # Pyre type checker 151 | .pyre/ 152 | 153 | # pytype static type analyzer 154 | .pytype/ 155 | 156 | # Cython debug symbols 157 | cython_debug/ 158 | 159 | # PyCharm 160 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 161 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 162 | # and can be added to the global gitignore or merged into this file. For a more nuclear 163 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 164 | #.idea/ 165 | 166 | ### Python Patch ### 167 | # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration 168 | poetry.toml 169 | 170 | # ruff 171 | .ruff_cache/ 172 | 173 | # LSP config files 174 | pyrightconfig.json 175 | 176 | ### venv ### 177 | # Virtualenv 178 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 179 | [Bb]in 180 | [Ii]nclude 181 | [Ll]ib 182 | [Ll]ib64 183 | [Ll]ocal 184 | [Ss]cripts 185 | pyvenv.cfg 186 | pip-selfcheck.json 187 | 188 | # End of https://www.toptal.com/developers/gitignore/api/venv,python 189 | coverage.json 190 | results.xml 191 | prometheus_data 192 | grafana_data 193 | bandwidth_monitor.env 194 | *.swp 195 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12-alpine AS builder 2 | WORKDIR /app 3 | COPY src pyproject.toml ./ 4 | RUN python -m pip install --no-cache-dir pip setuptools && \ 5 | python -m pip install --no-cache-dir --prefix=/install . && \ 6 | find /install '(' -type d -regex '.*/tests?' ')' -o \ 7 | '(' -type f -regex '.*/[^/]+\.py[co]$' ')' \ 8 | -print -exec rm -rf '{}' + && \ 9 | python -m pip cache purge && \ 10 | rm -rf /app/build 11 | 12 | FROM python:3.12-alpine 13 | RUN adduser -D mikrotik 14 | 15 | WORKDIR /app 16 | COPY --from=builder /install /usr/local 17 | 18 | USER mikrotik 19 | CMD ["mikrotik_bandwidth_monitor_exporter"] 20 | HEALTHCHECK --timeout=10s CMD wget -T 1 --spider http://localhost:9180/ 21 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 jagub2 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MikroTik Bandwidth Monitor Exporter 2 | 3 | This is a bandwidth monitor exporter for Prometheus intended for use with MikroTiks. 4 | 5 | ## How to set up MikroTik router 6 | 7 | This exporter is based on MikroTik's Kid-control mechanism. To enable it, you need to do the following: 8 | 9 | `/ip kid-control add name=Monitor mon=0s-1d tue=0s-1d wed=0s-1d thu=0s-1d fri=0s-1d sat=0s-1d sun=0s-1d` 10 | 11 | The name can be arbitrary, it does not matter. 12 | 13 | Then I strongly recommend to create a separate account than admin just for the sake of this exporter. Remember that that you need to give this account `write` permissions, as the exporter resets device counters after each query (to keep track of the used bandwidth). 14 | 15 | The exporter relies on the REST API, so you need make ensure WebFig is running. REST API requires RouterOS 7.1+, [more details here](https://help.mikrotik.com/docs/display/ROS/REST+API)). 16 | 17 | ## How to run it 18 | 19 | The repository contains a Docker stack with Grafana and Prometheus as a sample distribution. You can tune their needs, especially to integrate this exporter for your needs. 20 | 21 | Prometheus is set to poll the exporter every 15 minutes. I tried 1 minute intervals and it just worked fine for my hAP ac. 22 | 23 | The exporter relies on environment variables, you can set: 24 | 25 | - `MIKROTIK_IP`: IP to your MikroTik router (default value: `192.168.88.1`), 26 | - `MIKROTIK_WEBFIG_PORT`: port number to your MikroTik router WebFig (default value: `80`), 27 | - `MIKROTIK_REST_API_METHOD`: method of querying WebFig, one could use `http` or `https` (default value: `http`), 28 | - `MIKROTIK_USER`: username of used account on the MikroTik (default: `admin`, however I suggest to change it), 29 | - `MIKROTIK_PASSWORD`: self explainatory, password to the user account on the MikroTik, 30 | - `MIKROTIK_REST_API_VERIFY_SSL`: Bash "boolean" whether REST API SSL certificate should be checked (default: `0` (False)), 31 | - `MIKROTIK_REQUEST_TIMEOUT`: timeout for quering MikroTik WebFig, 32 | - `LISTEN_ADDRESS`: address to listen on (default: `0.0.0.0`, good enough for Docker, I suggest changing it on bare-metal deployments), 33 | - `LISTEN_PORT`: port number to listeon on (default: `9180`), keep in mind to update Prometheus config. 34 | 35 | ## Something's not working? 36 | 37 | Well, I tested it on my environment, it worked. I guess we could find a solution, feel free to contact me. 38 | 39 | ## License 40 | 41 | This project is licensed under MIT license, [more details here](LICENSE.txt). 42 | -------------------------------------------------------------------------------- /activate: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | if [ -z ${BASH_SOURCE+x} ]; then 3 | self="$0" 4 | else 5 | self="${BASH_SOURCE[0]}" 6 | fi 7 | root_dir="$(readlink -f "$(dirname -- "${self}")")" 8 | install_param=$1; shift 9 | if ! test -d "${root_dir:?}/venv" || \ 10 | ! test -f "${root_dir:?}/venv/bin/activate" || \ 11 | [[ "${install_param}" == "-i" ]]; then 12 | rm -rf "${root_dir:?}/venv" 13 | python3 -m venv venv 14 | # shellcheck source=/dev/null 15 | source venv/bin/activate 16 | python3 -m pip install '.[dev]' 17 | else 18 | # shellcheck source=/dev/null 19 | source venv/bin/activate 20 | fi 21 | -------------------------------------------------------------------------------- /bandwidth_monitor.sample.env: -------------------------------------------------------------------------------- 1 | MIKROTIK_IP=192.168.88.1 2 | MIKROTIK_WEBFIG_PORT=80 3 | MIKROTIK_REST_API_METHOD=http 4 | MIKROTIK_USER=admin 5 | MIKROTIK_PASSWORD= 6 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | networks: 4 | front-tier: 5 | back-tier: 6 | 7 | services: 8 | prometheus: 9 | image: prom/prometheus:v2.25.2 10 | restart: unless-stopped 11 | volumes: 12 | - ./prometheus.yml:/etc/prometheus/prometheus.yml 13 | - ./prometheus_data:/prometheus 14 | command: 15 | - '--config.file=/etc/prometheus/prometheus.yml' 16 | - '--storage.tsdb.path=/prometheus' 17 | - '--web.console.libraries=/etc/prometheus/console_libraries' 18 | - '--web.console.templates=/etc/prometheus/consoles' 19 | - '--web.enable-lifecycle' 20 | depends_on: 21 | - bandwidth_monitor 22 | ports: 23 | - 19090:9090 24 | networks: 25 | - back-tier 26 | 27 | bandwidth_monitor: 28 | image: ghcr.io/jagub2/mikrotik_bandwidth_monitor_exporter:latest 29 | restart: unless-stopped 30 | env_file: 31 | - bandwidth_monitor.env 32 | networks: 33 | - back-tier 34 | 35 | grafana: 36 | image: grafana/grafana 37 | restart: unless-stopped 38 | volumes: 39 | - ./grafana/provisioning:/etc/grafana/provisioning 40 | - ./grafana_data:/var/lib/grafana 41 | depends_on: 42 | - prometheus 43 | ports: 44 | - '13000:3000' 45 | environment: 46 | - GF_SECURITY_ADMIN_PASSWORD=admin 47 | - GF_USERS_ALLOW_SIGN_UP=false 48 | - GF_INSTALL_PLUGINS=flant-statusmap-panel,ae3e-plotly-panel 49 | networks: 50 | - back-tier 51 | - front-tier 52 | 53 | -------------------------------------------------------------------------------- /grafana/provisioning/dashboards/bandwidth-monitor.json: -------------------------------------------------------------------------------- 1 | { 2 | "annotations": { 3 | "list": [ 4 | { 5 | "builtIn": 1, 6 | "datasource": { 7 | "type": "grafana", 8 | "uid": "-- Grafana --" 9 | }, 10 | "enable": true, 11 | "hide": true, 12 | "iconColor": "rgba(0, 211, 255, 1)", 13 | "name": "Annotations & Alerts", 14 | "target": { 15 | "limit": 100, 16 | "matchAny": false, 17 | "tags": [], 18 | "type": "dashboard" 19 | }, 20 | "type": "dashboard" 21 | } 22 | ] 23 | }, 24 | "editable": true, 25 | "graphTooltip": 0, 26 | "id": 4, 27 | "links": [], 28 | "liveNow": false, 29 | "panels": [ 30 | { 31 | "datasource": "prometheus", 32 | "fieldConfig": { 33 | "defaults": { 34 | "color": { 35 | "mode": "palette-classic" 36 | }, 37 | "custom": { 38 | "hideFrom": { 39 | "legend": false, 40 | "tooltip": false, 41 | "viz": false 42 | } 43 | }, 44 | "mappings": [], 45 | "unit": "decmbytes" 46 | }, 47 | "overrides": [] 48 | }, 49 | "gridPos": { 50 | "h": 19, 51 | "w": 12, 52 | "x": 0, 53 | "y": 0 54 | }, 55 | "id": 7, 56 | "options": { 57 | "legend": { 58 | "displayMode": "list", 59 | "placement": "bottom", 60 | "showLegend": true 61 | }, 62 | "pieType": "pie", 63 | "reduceOptions": { 64 | "calcs": [ 65 | "lastNotNull" 66 | ], 67 | "fields": "", 68 | "values": false 69 | }, 70 | "tooltip": { 71 | "mode": "single", 72 | "sort": "none" 73 | } 74 | }, 75 | "targets": [ 76 | { 77 | "datasource": "prometheus", 78 | "editorMode": "code", 79 | "expr": "sum_over_time(bytes_down[$__range])/1048576 != 0", 80 | "legendFormat": "{{name}}", 81 | "range": true, 82 | "refId": "A" 83 | } 84 | ], 85 | "title": "Data down per device in a range", 86 | "type": "piechart" 87 | }, 88 | { 89 | "datasource": "prometheus", 90 | "fieldConfig": { 91 | "defaults": { 92 | "color": { 93 | "mode": "palette-classic" 94 | }, 95 | "custom": { 96 | "hideFrom": { 97 | "legend": false, 98 | "tooltip": false, 99 | "viz": false 100 | } 101 | }, 102 | "mappings": [], 103 | "unit": "decmbytes" 104 | }, 105 | "overrides": [] 106 | }, 107 | "gridPos": { 108 | "h": 19, 109 | "w": 12, 110 | "x": 12, 111 | "y": 0 112 | }, 113 | "id": 8, 114 | "options": { 115 | "legend": { 116 | "displayMode": "list", 117 | "placement": "bottom", 118 | "showLegend": true 119 | }, 120 | "pieType": "pie", 121 | "reduceOptions": { 122 | "calcs": [ 123 | "lastNotNull" 124 | ], 125 | "fields": "", 126 | "values": false 127 | }, 128 | "tooltip": { 129 | "mode": "single", 130 | "sort": "none" 131 | } 132 | }, 133 | "targets": [ 134 | { 135 | "datasource": "prometheus", 136 | "editorMode": "code", 137 | "expr": "sum_over_time(bytes_up[$__range])/1048576 != 0", 138 | "legendFormat": "{{name}}", 139 | "range": true, 140 | "refId": "A" 141 | } 142 | ], 143 | "title": "Data up per device in a range", 144 | "type": "piechart" 145 | }, 146 | { 147 | "datasource": "prometheus", 148 | "fieldConfig": { 149 | "defaults": { 150 | "color": { 151 | "mode": "palette-classic" 152 | }, 153 | "custom": { 154 | "axisBorderShow": false, 155 | "axisCenteredZero": false, 156 | "axisColorMode": "text", 157 | "axisLabel": "Used bandwidth", 158 | "axisPlacement": "auto", 159 | "barAlignment": 0, 160 | "drawStyle": "line", 161 | "fillOpacity": 0, 162 | "gradientMode": "none", 163 | "hideFrom": { 164 | "legend": false, 165 | "tooltip": false, 166 | "viz": false 167 | }, 168 | "insertNulls": false, 169 | "lineInterpolation": "smooth", 170 | "lineStyle": { 171 | "fill": "solid" 172 | }, 173 | "lineWidth": 1, 174 | "pointSize": 5, 175 | "scaleDistribution": { 176 | "type": "linear" 177 | }, 178 | "showPoints": "auto", 179 | "spanNulls": 1200000, 180 | "stacking": { 181 | "group": "A", 182 | "mode": "none" 183 | }, 184 | "thresholdsStyle": { 185 | "mode": "off" 186 | } 187 | }, 188 | "mappings": [], 189 | "thresholds": { 190 | "mode": "absolute", 191 | "steps": [ 192 | { 193 | "color": "green", 194 | "value": null 195 | } 196 | ] 197 | }, 198 | "unit": "decmbytes" 199 | }, 200 | "overrides": [] 201 | }, 202 | "gridPos": { 203 | "h": 19, 204 | "w": 12, 205 | "x": 0, 206 | "y": 19 207 | }, 208 | "id": 2, 209 | "options": { 210 | "legend": { 211 | "calcs": [], 212 | "displayMode": "list", 213 | "placement": "bottom", 214 | "showLegend": true 215 | }, 216 | "tooltip": { 217 | "mode": "single", 218 | "sort": "none" 219 | } 220 | }, 221 | "targets": [ 222 | { 223 | "datasource": "prometheus", 224 | "editorMode": "code", 225 | "exemplar": false, 226 | "expr": "bytes_down / 1048576 != 0", 227 | "format": "time_series", 228 | "instant": false, 229 | "interval": "", 230 | "legendFormat": "{{name}}", 231 | "range": true, 232 | "refId": "A" 233 | } 234 | ], 235 | "title": "Data down", 236 | "type": "timeseries" 237 | }, 238 | { 239 | "datasource": "prometheus", 240 | "fieldConfig": { 241 | "defaults": { 242 | "color": { 243 | "mode": "palette-classic" 244 | }, 245 | "custom": { 246 | "axisBorderShow": false, 247 | "axisCenteredZero": false, 248 | "axisColorMode": "text", 249 | "axisLabel": "Used bandwidth", 250 | "axisPlacement": "auto", 251 | "barAlignment": 0, 252 | "drawStyle": "line", 253 | "fillOpacity": 0, 254 | "gradientMode": "none", 255 | "hideFrom": { 256 | "legend": false, 257 | "tooltip": false, 258 | "viz": false 259 | }, 260 | "insertNulls": false, 261 | "lineInterpolation": "smooth", 262 | "lineStyle": { 263 | "fill": "solid" 264 | }, 265 | "lineWidth": 1, 266 | "pointSize": 5, 267 | "scaleDistribution": { 268 | "type": "linear" 269 | }, 270 | "showPoints": "auto", 271 | "spanNulls": 1200000, 272 | "stacking": { 273 | "group": "A", 274 | "mode": "none" 275 | }, 276 | "thresholdsStyle": { 277 | "mode": "off" 278 | } 279 | }, 280 | "mappings": [], 281 | "thresholds": { 282 | "mode": "absolute", 283 | "steps": [ 284 | { 285 | "color": "green", 286 | "value": null 287 | } 288 | ] 289 | }, 290 | "unit": "decmbytes" 291 | }, 292 | "overrides": [] 293 | }, 294 | "gridPos": { 295 | "h": 19, 296 | "w": 12, 297 | "x": 12, 298 | "y": 19 299 | }, 300 | "id": 3, 301 | "options": { 302 | "legend": { 303 | "calcs": [], 304 | "displayMode": "list", 305 | "placement": "bottom", 306 | "showLegend": true 307 | }, 308 | "tooltip": { 309 | "mode": "single", 310 | "sort": "none" 311 | } 312 | }, 313 | "targets": [ 314 | { 315 | "datasource": "prometheus", 316 | "editorMode": "code", 317 | "exemplar": false, 318 | "expr": "bytes_up / 1048576 != 0", 319 | "format": "time_series", 320 | "instant": false, 321 | "interval": "", 322 | "legendFormat": "{{name}}", 323 | "range": true, 324 | "refId": "A" 325 | } 326 | ], 327 | "title": "Data up", 328 | "type": "timeseries" 329 | } 330 | ], 331 | "refresh": "", 332 | "schemaVersion": 39, 333 | "tags": [], 334 | "templating": { 335 | "list": [] 336 | }, 337 | "time": { 338 | "from": "now-24h", 339 | "to": "now" 340 | }, 341 | "timepicker": {}, 342 | "timezone": "", 343 | "title": "Bandwidth usage", 344 | "uid": "6VhjXkcIk", 345 | "version": 1, 346 | "weekStart": "" 347 | } 348 | -------------------------------------------------------------------------------- /grafana/provisioning/dashboards/dashboard.yml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: 1 3 | 4 | providers: 5 | - name: 'prometheus' 6 | orgId: 1 7 | folder: '' 8 | type: file 9 | disableDeletion: false 10 | editable: true 11 | options: 12 | path: /etc/grafana/provisioning/dashboards 13 | -------------------------------------------------------------------------------- /grafana/provisioning/datasources/datasource.yml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: 1 3 | 4 | # list of datasources that should be deleted from the database 5 | deleteDatasources: 6 | - name: prometheus 7 | orgId: 1 8 | 9 | # list of datasources to insert/update depending 10 | # whats available in the database 11 | datasources: 12 | - name: prometheus 13 | type: prometheus 14 | access: proxy 15 | orgId: 1 16 | url: http://prometheus:9090 17 | password: 18 | user: 19 | database: 20 | basicAuth: true 21 | basicAuthUser: admin 22 | basicAuthPassword: foobar 23 | withCredentials: 24 | isDefault: 25 | jsonData: 26 | graphiteVersion: "1.1" 27 | tlsAuth: false 28 | tlsAuthWithCACert: false 29 | secureJsonData: 30 | tlsCACert: "..." 31 | tlsClientCert: "..." 32 | tlsClientKey: "..." 33 | version: 1 34 | editable: true 35 | -------------------------------------------------------------------------------- /prometheus.yml: -------------------------------------------------------------------------------- 1 | global: 2 | scrape_interval: 1m 3 | 4 | scrape_configs: 5 | - job_name: 'prometheus' 6 | scrape_interval: 1m 7 | static_configs: 8 | - targets: ['localhost:9090'] 9 | 10 | - job_name: 'bandwidth_monitor' 11 | scrape_interval: 15m 12 | scrape_timeout: 30s 13 | static_configs: 14 | - targets: ['bandwidth_monitor:9180'] 15 | 16 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "mikrotik_bandwidth_monitor_exporter" 3 | version = "0.1.0" 4 | dependencies = [ 5 | "flask>=3.0.0", 6 | "requests>=2.31.0", 7 | "waitress>=2.1.2", 8 | "prometheus-client>=0.19.0", 9 | ] 10 | requires-python = ">=3.8" 11 | authors = [ 12 | {name = "jagub2"}, 13 | ] 14 | maintainers = [ 15 | {name = "jagub2"}, 16 | ] 17 | description = "Mikrotik Bandwidth Exporter plugin for Prometheus" 18 | readme = "README.md" 19 | license = {file = "LICENSE.txt"} 20 | classifiers = [ 21 | "Programming Language :: Python" 22 | ] 23 | 24 | [project.urls] 25 | Homepage = "https://github.com/jagub2/mikrotik_bandwidth_monitor_exporter" 26 | 27 | [project.scripts] 28 | mikrotik_bandwidth_monitor_exporter = "mikrotik_bandwidth_monitor_exporter.exporter:main" 29 | 30 | [build-system] 31 | requires = ["setuptools>=60.0.0", "wheel"] 32 | build-backend = "setuptools.build_meta" 33 | 34 | [project.optional-dependencies] 35 | dev = [ 36 | "tox>=4", 37 | "tox-ignore-env-name-mismatch" 38 | ] 39 | 40 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jagub2/mikrotik_bandwidth_monitor_exporter/b7b8d3218f4f077d7842bed8986be28cc50bd989/src/__init__.py -------------------------------------------------------------------------------- /src/mikrotik_bandwidth_monitor_exporter/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jagub2/mikrotik_bandwidth_monitor_exporter/b7b8d3218f4f077d7842bed8986be28cc50bd989/src/mikrotik_bandwidth_monitor_exporter/__init__.py -------------------------------------------------------------------------------- /src/mikrotik_bandwidth_monitor_exporter/exporter.py: -------------------------------------------------------------------------------- 1 | """Mikrotik Bandwidth Monitor Exporter Python module.""" 2 | import logging 3 | import os 4 | from dataclasses import dataclass 5 | from typing import Callable 6 | import requests 7 | from flask import current_app, Flask 8 | from prometheus_client import make_wsgi_app, Gauge 9 | from waitress import serve 10 | 11 | app = Flask("Mikrotik-Bandwidth-Monitor-Exporter") 12 | 13 | FORMAT_STRING = 'level=%(levelname)s datetime=%(asctime)s %(message)s' 14 | logging.basicConfig(encoding='utf-8', 15 | level=logging.DEBUG, 16 | format=FORMAT_STRING) 17 | 18 | log = logging.getLogger('waitress') 19 | log.disabled = True 20 | 21 | bytes_down = Gauge('bytes_down', 'Bytes down for given host', 22 | labelnames=['mac', 'name']) 23 | bytes_up = Gauge('bytes_up', 'Bytes up for given host', 24 | labelnames=['mac', 'name']) 25 | 26 | 27 | @dataclass(frozen=True) 28 | class MikrotikDataclass: 29 | """Class for Mikrotik data.""" 30 | webfig_url: str 31 | api_login: str 32 | api_password: str 33 | verify_ssl: bool = False 34 | request_timeout: int = 30 35 | 36 | def generate_auth(self) -> requests.auth.HTTPBasicAuth: 37 | """Return HTTPBasicAuth with login data.""" 38 | return requests.auth.HTTPBasicAuth(self.api_login, self.api_password) 39 | 40 | 41 | def is_http_code_ok(request_status_code: requests.codes) -> bool: 42 | """Check whether request's HTTP status code was OK.""" 43 | # pylint: disable=no-member 44 | return request_status_code == requests.codes.ok 45 | 46 | 47 | @app.route("/metrics") 48 | def get_kid_control_data_metrics() -> Callable: 49 | """Function to query MikroTik Kid Control data to return the metrics.""" 50 | mikrotik_data = current_app.config['MIKROTIK_DATA'] 51 | mikrotik_auth = mikrotik_data.generate_auth() 52 | data_request = requests.get( 53 | f"{mikrotik_data.webfig_url}/rest/ip/kid-control/device", 54 | auth=mikrotik_auth, 55 | verify=mikrotik_data.verify_ssl, 56 | timeout=mikrotik_data.request_timeout, 57 | ) 58 | if is_http_code_ok(data_request.status_code): 59 | devices = { 60 | entry['mac-address'].upper(): entry 61 | for entry in data_request.json() 62 | } 63 | for device, device_data in devices.items(): 64 | device_name = device_data['name'] 65 | if not device_name: 66 | device_name = device 67 | bytes_down.labels(mac=device, name=device_name).\ 68 | set(device_data['bytes-down']) 69 | bytes_up.labels(mac=device, name=device_name).\ 70 | set(device_data['bytes-up']) 71 | reset_request = requests.post( 72 | f"{mikrotik_data.webfig_url}/rest/ip/kid-control/device/" 73 | "reset-counters", 74 | auth=mikrotik_auth, 75 | headers={"Content-Type": "application/json"}, 76 | data={}, 77 | verify=mikrotik_data.verify_ssl, 78 | timeout=mikrotik_data.request_timeout, 79 | ) 80 | if not is_http_code_ok(reset_request.status_code): 81 | raise requests.exceptions.HTTPError("Reset request failed") 82 | else: 83 | raise requests.exceptions.HTTPError("Data request failed") 84 | 85 | return make_wsgi_app() 86 | 87 | 88 | @app.route("/") 89 | def main_page() -> str: 90 | """Serve the main page. 91 | 92 | Returns: 93 | str: basic HTML with main page 94 | """ 95 | return ("

Welcome to Mikrotik Bandwidth-Monitor data exporter.

" + 96 | "Metrics are available: here.") 97 | 98 | 99 | def main(): 100 | """Main processing function.""" 101 | address = os.getenv("LISTEN_ADDRESS", "0.0.0.0") 102 | port = os.getenv("LISTEN_PORT", "9180") 103 | logging.info("Starting Mikrotik-Bandwidth-Monitor-Exporter on " 104 | "http://localhost:%s", str(port)) 105 | mikrotik_webfig_url = f"{os.getenv('MIKROTIK_REST_API_METHOD', 'http')}" \ 106 | f"://{os.getenv('MIKROTIK_IP', '192.168.88.1')}:" \ 107 | f"{os.getenv('MIKROTIK_WEBFIG_PORT', '80')}" 108 | mikrotik_dataclass = MikrotikDataclass( 109 | webfig_url=mikrotik_webfig_url, 110 | api_login=os.getenv("MIKROTIK_USER", "admin"), 111 | api_password=os.getenv("MIKROTIK_PASSWORD", ""), 112 | verify_ssl=os.getenv("MIKROTIK_REST_API_VERIFY_SSL", "0") == "1", 113 | request_timeout=int(os.getenv("MIKROTIK_REQUEST_TIMEOUT", "30")) 114 | ) 115 | if not mikrotik_dataclass.verify_ssl: 116 | requests.packages.urllib3.\ 117 | disable_warnings() # pylint: disable=no-member 118 | app.config['MIKROTIK_DATA'] = mikrotik_dataclass 119 | serve(app, host=address, port=int(port)) 120 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jagub2/mikrotik_bandwidth_monitor_exporter/b7b8d3218f4f077d7842bed8986be28cc50bd989/tests/__init__.py -------------------------------------------------------------------------------- /tests/mikrotik_bandwidth_monitor_exporter_test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jagub2/mikrotik_bandwidth_monitor_exporter/b7b8d3218f4f077d7842bed8986be28cc50bd989/tests/mikrotik_bandwidth_monitor_exporter_test/__init__.py -------------------------------------------------------------------------------- /tests/mikrotik_bandwidth_monitor_exporter_test/exporter_test.py: -------------------------------------------------------------------------------- 1 | """Test suite for mikrotik_bandwidth_monitor_exporter module.""" 2 | # pylint: disable=protected-access 3 | from functools import wraps 4 | 5 | import json 6 | import pytest 7 | import requests 8 | import requests_mock 9 | import mikrotik_bandwidth_monitor_exporter.exporter as exporter_module 10 | 11 | 12 | devices_mock = [ 13 | { 14 | ".id": "*1", 15 | "activity": "", 16 | "blocked": "false", 17 | "bytes-down": "42069", 18 | "bytes-up": "2137", 19 | "disabled": "false", 20 | "dynamic": "true", 21 | "idle-time": "1m1s", 22 | "ip-address": "192.168.88.2", 23 | "limited": "false", 24 | "mac-address": "AA:BB:CC:DD:EE:FF", 25 | "name": "foobar", 26 | "rate-down": "0", 27 | "rate-up": "0", 28 | "user": "" 29 | }, 30 | { 31 | ".id": "*2", 32 | "activity": "", 33 | "blocked": "false", 34 | "bytes-down": "69420", 35 | "bytes-up": "7312", 36 | "disabled": "false", 37 | "dynamic": "true", 38 | "idle-time": "1m1s", 39 | "ip-address": "192.168.88.3", 40 | "limited": "false", 41 | "mac-address": "AA:BB:CC:DD:EE:00", 42 | "name": "", 43 | "rate-down": "0", 44 | "rate-up": "0", 45 | "user": "" 46 | }, 47 | ] 48 | 49 | 50 | @pytest.fixture(name="mikrotik_dataclass") 51 | def mikrotik_dataclass_fixture(): 52 | """Fixture of MikrotikDataclass.""" 53 | return exporter_module.MikrotikDataclass( 54 | 'http://192.168.88.1', 'admin', '') 55 | 56 | 57 | def flask_app_wrapper(func): 58 | """Decorator for flask app.""" 59 | @wraps(func) 60 | def inner_flask_app(*args, **kwargs): 61 | with exporter_module.app.app_context(): 62 | return func(*args, **kwargs) 63 | return inner_flask_app 64 | 65 | 66 | @pytest.fixture(name="requests_warning_mock") 67 | def requests_warnings_fixture(mocker): 68 | """Fixture of disabling warnings of requests package urllib3.""" 69 | return mocker.patch.object( 70 | requests.packages.urllib3, # pylint: disable=no-member 71 | "disable_warnings") 72 | 73 | 74 | @pytest.fixture(name="serve_mock") 75 | def serve_mock_fixture(mocker): 76 | """Mock of serve() from waitress module.""" 77 | return mocker.patch.object(exporter_module, "serve") 78 | 79 | 80 | class TestMikrotikDataclass: 81 | """Test for MikrotikDataclass.""" 82 | def test_fields(self, mikrotik_dataclass): 83 | """Test field values.""" 84 | assert mikrotik_dataclass.webfig_url == 'http://192.168.88.1' 85 | assert mikrotik_dataclass.api_login == 'admin' 86 | assert mikrotik_dataclass.api_password == '' 87 | assert mikrotik_dataclass.request_timeout == 30 88 | assert not mikrotik_dataclass.verify_ssl 89 | 90 | def test_generate_auth(self, mikrotik_dataclass): 91 | """Test generate_auth().""" 92 | web_auth = mikrotik_dataclass.generate_auth() 93 | assert isinstance(web_auth, requests.auth.HTTPBasicAuth) 94 | assert web_auth.username == mikrotik_dataclass.api_login 95 | assert web_auth.password == mikrotik_dataclass.api_password 96 | 97 | 98 | class TestHttpResponses: 99 | """Class for HTTP response tests.""" 100 | def test_correct_http_response(self): 101 | """Test for correct HTTP response code.""" 102 | assert exporter_module.is_http_code_ok(200) 103 | 104 | @pytest.mark.parametrize("http_code", [400, 401, 420, 500]) 105 | def test_incorrect_http_response(self, http_code): 106 | """Test for incorrect HTTP response code.""" 107 | assert not exporter_module.is_http_code_ok(http_code) 108 | 109 | 110 | class TestGetKidControlDataMetrics(): 111 | """Test get_kid_control_data_metrics.""" 112 | def prepare(self, exporter_mod, mocker, mikrotik_dataclass, **kwargs): 113 | """Preparation for tests in this class.""" 114 | requests_mocker = kwargs['requests_mocker'] 115 | exporter = exporter_mod 116 | current_app_mock = mocker.patch.object(exporter, "current_app") 117 | current_app_mock.config = {'MIKROTIK_DATA': mikrotik_dataclass} 118 | make_wsgi_app_mock = mocker.patch.object(exporter, "make_wsgi_app") 119 | return requests_mocker, exporter, current_app_mock, make_wsgi_app_mock 120 | 121 | @flask_app_wrapper 122 | @requests_mock.Mocker(kw='requests_mocker') 123 | def test_get_metrics(self, mocker, mikrotik_dataclass, **kwargs): 124 | """Test for get_kid_control_data_metrics() where everything works.""" 125 | requests_mocker, exporter, _, make_wsgi_app_mock = \ 126 | self.prepare(exporter_module, mocker, mikrotik_dataclass, **kwargs) 127 | requests_mocker.get('http://192.168.88.1/rest/ip/kid-control/device', 128 | text=json.dumps(devices_mock)) 129 | requests_mocker.post('http://192.168.88.1/rest/ip/kid-control/device' 130 | '/reset-counters') 131 | result = exporter.get_kid_control_data_metrics() 132 | assert result is make_wsgi_app_mock.return_value 133 | assert [s.value for s in exporter.bytes_down._samples()] == \ 134 | [42069.0, 69420.0] 135 | assert [s.value for s in exporter.bytes_up._samples()] == \ 136 | [2137.0, 7312.0] 137 | assert {'AA:BB:CC:DD:EE:FF', 'AA:BB:CC:DD:EE:00'} & \ 138 | set(s.labels['mac'] for s in exporter.bytes_down._samples()) & \ 139 | set(s.labels['mac'] for s in exporter.bytes_up._samples()) 140 | assert {'foobar', 'AA:BB:CC:DD:EE:00'} & \ 141 | set(s.labels['name'] for s in exporter.bytes_down._samples()) & \ 142 | set(s.labels['name'] for s in exporter.bytes_up._samples()) 143 | 144 | @flask_app_wrapper 145 | @requests_mock.Mocker(kw='requests_mocker') 146 | def test_get_metrics_but_it_fails_1st(self, mocker, mikrotik_dataclass, 147 | **kwargs): 148 | """Test for get_kid_control_data_metrics() with fail, 1st scenario.""" 149 | requests_mocker, exporter, _, make_wsgi_app_mock = \ 150 | self.prepare(exporter_module, mocker, mikrotik_dataclass, **kwargs) 151 | requests_mocker.get('http://192.168.88.1/rest/ip/kid-control/device', 152 | status_code=500) 153 | with pytest.raises(requests.exceptions.HTTPError) as exception: 154 | exporter.get_kid_control_data_metrics() 155 | assert str(exception.value) == "Data request failed" 156 | make_wsgi_app_mock.assert_not_called() 157 | 158 | @flask_app_wrapper 159 | @requests_mock.Mocker(kw='requests_mocker') 160 | def test_get_metrics_but_it_fails_2nd(self, mocker, mikrotik_dataclass, 161 | **kwargs): 162 | """Test for get_kid_control_data_metrics() with fail, 1st scenario.""" 163 | requests_mocker, exporter, _, make_wsgi_app_mock = \ 164 | self.prepare(exporter_module, mocker, mikrotik_dataclass, **kwargs) 165 | requests_mocker.get('http://192.168.88.1/rest/ip/kid-control/device', 166 | text=json.dumps(devices_mock)) 167 | requests_mocker.post('http://192.168.88.1/rest/ip/kid-control/device' 168 | '/reset-counters', status_code=500) 169 | with pytest.raises(requests.exceptions.HTTPError) as exception: 170 | exporter.get_kid_control_data_metrics() 171 | assert str(exception.value) == "Reset request failed" 172 | make_wsgi_app_mock.assert_not_called() 173 | 174 | 175 | def test_main_page(): 176 | """Test main_page()""" 177 | assert exporter_module.main_page() == \ 178 | "

Welcome to Mikrotik Bandwidth-Monitor data exporter.

" + \ 179 | "Metrics are available: here." 180 | 181 | 182 | class TestMain(): 183 | """Test main()""" 184 | def test_init(self, serve_mock, requests_warning_mock): 185 | """Test default initialization.""" 186 | exporter_module.main() 187 | mikrotik_dataclass = exporter_module.app.config['MIKROTIK_DATA'] 188 | assert mikrotik_dataclass.webfig_url == 'http://192.168.88.1:80' 189 | assert mikrotik_dataclass.api_login == 'admin' 190 | assert mikrotik_dataclass.api_password == '' 191 | assert mikrotik_dataclass.request_timeout == 30 192 | assert not mikrotik_dataclass.verify_ssl 193 | assert isinstance( 194 | mikrotik_dataclass, exporter_module.MikrotikDataclass) 195 | requests_warning_mock.assert_called() 196 | serve_mock.assert_called_with(exporter_module.app, 197 | host="0.0.0.0", port=9180) 198 | 199 | def test_init_with_env_variables( 200 | self, serve_mock, requests_warning_mock, mocker): 201 | """Test initialization with environment variables.""" 202 | mocker.patch.dict("os.environ", { 203 | "LISTEN_ADDRESS": "127.0.0.1", 204 | "LISTEN_PORT": "2137", 205 | "MIKROTIK_REST_API_METHOD": "https", 206 | "MIKROTIK_IP": "192.168.1.1", 207 | "MIKROTIK_WEBFIG_PORT": "443", 208 | "MIKROTIK_USER": "api", 209 | "MIKROTIK_PASSWORD": "e1m1", 210 | "MIKROTIK_REST_API_VERIFY_SSL": "1", 211 | "MIKROTIK_REQUEST_TIMEOUT": "10" 212 | }) 213 | exporter_module.main() 214 | mikrotik_dataclass = exporter_module.app.config['MIKROTIK_DATA'] 215 | assert mikrotik_dataclass.webfig_url == 'https://192.168.1.1:443' 216 | assert mikrotik_dataclass.api_login == 'api' 217 | assert mikrotik_dataclass.api_password == 'e1m1' 218 | assert mikrotik_dataclass.request_timeout == 10 219 | assert mikrotik_dataclass.verify_ssl 220 | requests_warning_mock.assert_not_called() 221 | serve_mock.assert_called_with(exporter_module.app, 222 | host="127.0.0.1", port=2137) 223 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = flake8,pylint,pytest,coverage 3 | requires = tox-ignore-env-name-mismatch 4 | 5 | [gh] 6 | python = 7 | 3.12 = py312 8 | 9 | [testenv] 10 | usedevelop = True 11 | 12 | [testdeps] 13 | deps = 14 | pytest 15 | pytest-mock 16 | requests-mock 17 | coverage 18 | 19 | [testenv:{flake8,pylint}] 20 | envdir = {work_dir}/code_quality 21 | runner = ignore_env_name_mismatch 22 | deps = 23 | flake8 24 | pylint 25 | {[testdeps]deps} 26 | commands = 27 | flake8: flake8 src tests 28 | pylint: pylint --rcfile=tox.ini src tests 29 | 30 | [flake8] 31 | exclude = .tox,venv,build 32 | 33 | [testenv:pytest] 34 | deps = 35 | {[testdeps]deps} 36 | commands = 37 | pytest {posargs} 38 | 39 | [testenv:coverage] 40 | deps = 41 | {[testdeps]deps} 42 | commands = 43 | coverage run --source=. -m pytest {posargs: {toxinidir}/tests/} --junitxml=results.xml 44 | coverage json -o coverage.json 45 | coverage report -m --fail-under=90 46 | 47 | --------------------------------------------------------------------------------