├── tests ├── conffiles │ ├── empty.yaml │ ├── password.txt │ ├── emptydevices.yaml │ ├── nodevices.yaml │ ├── malformeddevice.yaml │ ├── password_file.yaml │ ├── invalidport.yaml │ ├── namesnotunique.yaml │ └── validconfig.yaml ├── __init__.py ├── test_main.py ├── test_config.py ├── test_datadonation.py └── test_fritzdevice.py ├── .dockerignore ├── .release-please-manifest.json ├── docs ├── _static │ ├── dashboard.png │ ├── datasrc_ok.png │ ├── fritz_metrics.png │ ├── grafana_datasrc.png │ ├── dashboard_import.png │ └── prometheus_target.png ├── Makefile ├── make.bat ├── fritz-exporter.service ├── conf.py ├── upgrading.rst ├── coding.rst ├── docker-images.rst ├── building.rst ├── configuration.rst ├── quickstart.rst ├── helping_out.rst ├── running.rst └── index.rst ├── .coveragerc ├── .flake8 ├── release-please-config.json ├── helm └── fritz-exporter │ ├── Chart.yaml │ ├── templates │ ├── secret.yaml │ ├── serviceaccount.yaml │ ├── service.yaml │ ├── tests │ │ └── test-connection.yaml │ ├── servicemonitor.yaml │ ├── _helpers.tpl │ └── deployment.yaml │ ├── .helmignore │ └── values.yaml ├── update-version-files.js ├── .git-hooks └── commit-msg ├── .github ├── dependabot.yml └── workflows │ ├── build-trunk.yaml │ ├── run-tests.yaml │ └── release.yaml ├── sonar-project.properties ├── .vscode ├── settings.json └── launch.json ├── fritzexporter ├── fritz_aha.py ├── exceptions.py ├── __init__.py ├── config │ ├── __init__.py │ ├── exceptions.py │ └── config.py ├── action_blacklists.py ├── __main__.py ├── fritzdevice.py └── data_donation.py ├── Dockerfile ├── git-conventional-commits.yaml ├── .readthedocs.yaml ├── fritz_export_helper.py ├── .gitignore ├── README.md ├── pyproject.toml ├── CHANGELOG.md └── LICENSE.md /tests/conffiles/empty.yaml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | docs/ 2 | .vscode/ 3 | .github/ -------------------------------------------------------------------------------- /tests/conffiles/password.txt: -------------------------------------------------------------------------------- 1 | prometheus2 2 | -------------------------------------------------------------------------------- /tests/conffiles/emptydevices.yaml: -------------------------------------------------------------------------------- 1 | devices: [] 2 | -------------------------------------------------------------------------------- /.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | ".": "2.6.1" 3 | } -------------------------------------------------------------------------------- /tests/conffiles/nodevices.yaml: -------------------------------------------------------------------------------- 1 | exporter_port: 9787 2 | -------------------------------------------------------------------------------- /docs/_static/dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pdreker/fritz_exporter/HEAD/docs/_static/dashboard.png -------------------------------------------------------------------------------- /docs/_static/datasrc_ok.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pdreker/fritz_exporter/HEAD/docs/_static/datasrc_ok.png -------------------------------------------------------------------------------- /docs/_static/fritz_metrics.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pdreker/fritz_exporter/HEAD/docs/_static/fritz_metrics.png -------------------------------------------------------------------------------- /docs/_static/grafana_datasrc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pdreker/fritz_exporter/HEAD/docs/_static/grafana_datasrc.png -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | relative_files = True 4 | omit = tests/* 5 | 6 | [report] 7 | show_missing = true 8 | -------------------------------------------------------------------------------- /docs/_static/dashboard_import.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pdreker/fritz_exporter/HEAD/docs/_static/dashboard_import.png -------------------------------------------------------------------------------- /docs/_static/prometheus_target.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pdreker/fritz_exporter/HEAD/docs/_static/prometheus_target.png -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 100 3 | max-complexity = 10 4 | exclude = 5 | .git, 6 | __pycache__, 7 | docs/conf.py 8 | -------------------------------------------------------------------------------- /tests/conffiles/malformeddevice.yaml: -------------------------------------------------------------------------------- 1 | devices: 2 | - name: Fritz!Box 7590 Router 3 | hostname: fritz.box 4 | password: prometheus 5 | -------------------------------------------------------------------------------- /tests/conffiles/password_file.yaml: -------------------------------------------------------------------------------- 1 | exporter_port: 9787 2 | devices: 3 | - name: Fritz!Box 7590 Router 4 | hostname: fritz.box 5 | username: prometheus1 6 | password_file: tests/conffiles/password.txt 7 | -------------------------------------------------------------------------------- /release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "bootstrap-sha": "afae38491d69d711a2379584ba282cfeef59e0b8", 3 | "packages": { 4 | ".": { 5 | "release-type": "python", 6 | "package-name": "fritzexporter" 7 | } 8 | } 9 | } -------------------------------------------------------------------------------- /helm/fritz-exporter/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: fritz-exporter 3 | description: fritz-exporter Helm Chart 4 | type: application 5 | version: 0.3.0 6 | appVersion: "2.6.1" 7 | maintainers: 8 | - email: patrick@dreker.de 9 | name: Patrick Dreker 10 | -------------------------------------------------------------------------------- /update-version-files.js: -------------------------------------------------------------------------------- 1 | const fsMod = require('fs') 2 | 3 | exports.preCommit = (props) => { 4 | let versionContent = `VERSION = "${props.version}"` 5 | fsMod.writeFile('fritzexporter/_version.py', versionContent, (err) => { 6 | if (err) throw err; 7 | }) 8 | } 9 | -------------------------------------------------------------------------------- /helm/fritz-exporter/templates/secret.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Secret 3 | metadata: 4 | name: {{ include "fritz-exporter.fullname" . }}-config 5 | labels: 6 | {{- include "fritz-exporter.labels" . | nindent 4 }} 7 | data: 8 | config.yaml: {{ .Values.config | toYaml | b64enc }} 9 | -------------------------------------------------------------------------------- /.git-hooks/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | commit_message="$1" 4 | # exit with a non zero exit code incase of an invalid commit message 5 | 6 | # use git-conventional-commits, see https://github.com/qoomon/git-conventional-commits 7 | git-conventional-commits commit-msg-hook "$commit_message" 8 | -------------------------------------------------------------------------------- /tests/conffiles/invalidport.yaml: -------------------------------------------------------------------------------- 1 | exporter_port: 123456 2 | devices: 3 | - name: Fritz!Box 7590 Router 4 | hostname: fritz.box 5 | username: prometheus1 6 | password: prometheus2 7 | - name: Repeater Wohnzimmer 8 | hostname: repeater-Wohnzimmer 9 | username: prometheus3 10 | password: prometheus4 -------------------------------------------------------------------------------- /tests/conffiles/namesnotunique.yaml: -------------------------------------------------------------------------------- 1 | # Example Config File for Fritz-Exporter 2 | exporter_port: 9787 3 | devices: 4 | - name: Bla 5 | hostname: fritz.box 6 | username: prometheus1 7 | password: prometheus2 8 | - name: Bla 9 | hostname: repeater-Wohnzimmer 10 | username: prometheus3 11 | password: prometheus4 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | - package-ecosystem: "github-actions" 8 | directory: / 9 | schedule: 10 | interval: "weekly" 11 | - package-ecosystem: "docker" 12 | directory: / 13 | schedule: 14 | interval: "weekly" 15 | -------------------------------------------------------------------------------- /tests/conffiles/validconfig.yaml: -------------------------------------------------------------------------------- 1 | # Example Config File for Fritz-Exporter 2 | exporter_port: 9787 3 | listen_address: 127.0.0.1 4 | devices: 5 | - name: Fritz!Box 7590 Router 6 | hostname: fritz.box 7 | username: prometheus1 8 | password: prometheus2 9 | - name: Repeater Wohnzimmer 10 | hostname: repeater-Wohnzimmer 11 | username: prometheus3 12 | password: prometheus4 13 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.organization=pdreker 2 | sonar.projectKey=pdreker_fritz_exporter 3 | sonar.python.coverage.reportPaths=coverage.xml 4 | 5 | # relative paths to source directories. More details and properties are described 6 | # in https://sonarcloud.io/documentation/project-administration/narrowing-the-focus/ 7 | sonar.sources=fritzexporter/ 8 | sonar.tests=tests/ 9 | 10 | sonar.python.version=3.10 11 | -------------------------------------------------------------------------------- /helm/fritz-exporter/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceAccount.create -}} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: {{ include "fritz-exporter.serviceAccountName" . }} 6 | labels: 7 | {{- include "fritz-exporter.labels" . | nindent 4 }} 8 | {{- with .Values.serviceAccount.annotations }} 9 | annotations: 10 | {{- toYaml . | nindent 4 }} 11 | {{- end }} 12 | {{- end }} 13 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.testing.unittestEnabled": false, 3 | "python.testing.pytestEnabled": true, 4 | "python.testing.pytestArgs": [ 5 | "--cov", 6 | ".", 7 | "--cov-report", 8 | "xml", 9 | "--cov-branch", 10 | "--cov-config", 11 | ".coveragerc" 12 | ], 13 | "[python]": { 14 | "editor.defaultFormatter": "charliermarsh.ruff" 15 | }, 16 | } -------------------------------------------------------------------------------- /helm/fritz-exporter/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /helm/fritz-exporter/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "fritz-exporter.fullname" . }} 5 | labels: 6 | {{- include "fritz-exporter.labels" . | nindent 4 }} 7 | spec: 8 | type: {{ .Values.service.type }} 9 | ports: 10 | - port: {{ .Values.service.port }} 11 | targetPort: metrics 12 | protocol: TCP 13 | name: metrics 14 | selector: 15 | {{- include "fritz-exporter.selectorLabels" . | nindent 4 }} 16 | -------------------------------------------------------------------------------- /helm/fritz-exporter/templates/tests/test-connection.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Pod 3 | metadata: 4 | name: "{{ include "fritz-exporter.fullname" . }}-test-connection" 5 | labels: 6 | {{- include "fritz-exporter.labels" . | nindent 4 }} 7 | annotations: 8 | "helm.sh/hook": test 9 | spec: 10 | containers: 11 | - name: wget 12 | image: busybox 13 | command: ['wget'] 14 | args: ['{{ include "fritz-exporter.fullname" . }}:{{ .Values.service.port }}'] 15 | restartPolicy: Never 16 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Python: Module", 9 | "type": "python", 10 | "request": "launch", 11 | "module": "fritzexporter", 12 | "args": [ 13 | "--config", 14 | "../fritz-exporter.yaml" 15 | ], 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019-2025 Patrick Dreker 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | -------------------------------------------------------------------------------- /fritzexporter/fritz_aha.py: -------------------------------------------------------------------------------- 1 | from defusedxml import ElementTree 2 | 3 | 4 | def parse_aha_device_xml(deviceinfo: str) -> dict[str, str]: 5 | try: 6 | device: ElementTree = ElementTree.fromstring(deviceinfo) 7 | 8 | battery_level = device.find("battery") 9 | battery_low = device.find("batterylow") 10 | 11 | result = {} 12 | 13 | if battery_level is not None: 14 | result["battery_level"] = battery_level.text 15 | 16 | if battery_low is not None: 17 | result["battery_low"] = battery_low.text 18 | 19 | except ElementTree.ParseError: 20 | return {} 21 | else: 22 | return result 23 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /fritzexporter/exceptions.py: -------------------------------------------------------------------------------- 1 | class FritzDeviceHasNoCapabilitiesError(Exception): 2 | pass 3 | 4 | 5 | # Copyright 2019-2025 Patrick Dreker 6 | # 7 | # Licensed under the Apache License, Version 2.0 (the "License"); 8 | # you may not use this file except in compliance with the License. 9 | # You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, software 14 | # distributed under the License is distributed on an "AS IS" BASIS, 15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | # See the License for the specific language governing permissions and 17 | # limitations under the License. 18 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build using "poetry" in a separate build container 2 | # The resulting .whl file is then installed in the actual runner container 3 | FROM python:3.14.0-alpine AS build 4 | WORKDIR /app 5 | 6 | RUN apk add build-base libffi-dev openssl-dev rust cargo && \ 7 | pip install --upgrade pip && \ 8 | pip install poetry==1.8.2 9 | 10 | COPY README.md /app/ 11 | COPY pyproject.toml /app 12 | COPY poetry.lock /app 13 | COPY fritzexporter /app/fritzexporter 14 | 15 | RUN poetry build 16 | 17 | # Build the actual runner 18 | FROM python:3.14.0-alpine 19 | 20 | LABEL Name=fritzbox_exporter 21 | EXPOSE 9787 22 | ENV PIP_NO_CACHE_DIR="true" 23 | 24 | WORKDIR /app 25 | 26 | COPY --from=build /app/dist/*.whl / 27 | 28 | RUN pip install /fritz_exporter-*.whl && \ 29 | mkdir /etc/fritz && \ 30 | rm /fritz_exporter-*.whl 31 | 32 | ENTRYPOINT ["python", "-m", "fritzexporter"] 33 | -------------------------------------------------------------------------------- /fritzexporter/__init__.py: -------------------------------------------------------------------------------- 1 | from importlib import metadata 2 | 3 | try: 4 | __version__ = metadata.version(__package__) 5 | except metadata.PackageNotFoundError: 6 | __version__ = "develop" 7 | finally: 8 | del metadata 9 | 10 | 11 | # Copyright 2019-2025 Patrick Dreker 12 | # 13 | # Licensed under the Apache License, Version 2.0 (the "License"); 14 | # you may not use this file except in compliance with the License. 15 | # You may obtain a copy of the License at 16 | # 17 | # http://www.apache.org/licenses/LICENSE-2.0 18 | # 19 | # Unless required by applicable law or agreed to in writing, software 20 | # distributed under the License is distributed on an "AS IS" BASIS, 21 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 22 | # See the License for the specific language governing permissions and 23 | # limitations under the License. 24 | -------------------------------------------------------------------------------- /git-conventional-commits.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | convention: 3 | commitTypes: 4 | - feat 5 | - fix 6 | - perf 7 | - refactor 8 | - style 9 | - test 10 | - build 11 | - ops 12 | - docs 13 | - merge 14 | commitScopes: [] 15 | releaseTagGlobPattern: v[0-9]*.[0-9]*.[0-9]* 16 | changelog: 17 | commitTypes: 18 | - feat 19 | - fix 20 | - perf 21 | - merge 22 | includeInvalidCommits: true 23 | commitScopes: [] 24 | commitIgnoreRegexPattern: "^WIP " 25 | headlines: 26 | feat: Features 27 | fix: Bug Fixes 28 | perf: Performance Improvements 29 | merge: Merges 30 | breakingChange: BREAKING CHANGES 31 | commitUrl: https://github.com/pdreker/fritz_exporter/commit/%commit% 32 | commitRangeUrl: https://github.com/pdreker/fritz_exporter/compare/%from%...%to%?diff=split 33 | issueRegexPattern: "#[0-9]+" 34 | issueUrl: https://github.com/pdreker/fritz_exporter/issues/%issue% 35 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/fritz-exporter.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | After=network.target 3 | 4 | [Service] 5 | # These are security options which sandbox the service 6 | LockPersonality=true 7 | MemoryDenyWriteExecute=true 8 | NoNewPrivileges=true 9 | PrivateDevices=true 10 | PrivateTmp=true 11 | ProtectClock=true 12 | ProtectControlGroups=true 13 | ProtectHome=true 14 | ProtectHostname=true 15 | ProtectKernelLogs=true 16 | ProtectKernelModules=true 17 | ProtectKernelTunables=true 18 | ProtectSystem=strict 19 | RemoveIPC=true 20 | RestrictAddressFamilies=AF_INET 21 | RestrictAddressFamilies=AF_INET6 22 | RestrictNamespaces=true 23 | RestrictRealtime=true 24 | RestrictSUIDSGID=true 25 | SystemCallArchitectures=native 26 | 27 | # This gets run when the service starts up 28 | ExecStart=/usr/bin/python3 -m fritzexporter --config /etc/fritz-exporter/config.yaml 29 | User=fritz-exporter 30 | Group=fritz-exporter 31 | WorkingDirectory=/tmp 32 | Restart=always 33 | 34 | [Install] 35 | WantedBy=multi-user.target 36 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | 6 | # -- Project information ----------------------------------------------------- 7 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 8 | 9 | project = 'fritz-exporter' 10 | copyright = '2022, Patrick Dreker' 11 | author = 'Patrick Dreker' 12 | 13 | # -- General configuration --------------------------------------------------- 14 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 15 | 16 | extensions = [] 17 | 18 | templates_path = ['_templates'] 19 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 20 | 21 | 22 | 23 | # -- Options for HTML output ------------------------------------------------- 24 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 25 | 26 | html_theme = 'alabaster' 27 | html_static_path = ['_static'] 28 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file for Sphinx projects 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | # Required 5 | version: 2 6 | 7 | # Set the OS, Python version and other tools you might need 8 | build: 9 | os: ubuntu-22.04 10 | tools: 11 | python: "3.11" 12 | # You can also specify other tool versions: 13 | # nodejs: "20" 14 | # rust: "1.70" 15 | # golang: "1.20" 16 | 17 | # Build documentation in the "docs/" directory with Sphinx 18 | sphinx: 19 | configuration: docs/conf.py 20 | # You can configure Sphinx to use a different builder, for instance use the dirhtml builder for simpler URLs 21 | # builder: "dirhtml" 22 | # Fail on all warnings to avoid broken references 23 | # fail_on_warning: true 24 | 25 | # Optionally build your docs in additional formats such as PDF and ePub 26 | # formats: 27 | # - pdf 28 | # - epub 29 | 30 | # Optional but recommended, declare the Python requirements required 31 | # to build your documentation 32 | # See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html 33 | # python: 34 | # install: 35 | # - requirements: docs/requirements.txt -------------------------------------------------------------------------------- /helm/fritz-exporter/templates/servicemonitor.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceMonitor.enabled }} 2 | apiVersion: monitoring.coreos.com/v1 3 | kind: ServiceMonitor 4 | metadata: 5 | name: {{ template "fritz-exporter.fullname" . }} 6 | {{- if .Values.serviceMonitor.namespace }} 7 | namespace: {{ .Values.server.metrics.serviceMonitor.namespace }} 8 | {{- end }} 9 | labels: 10 | {{- include "fritz-exporter.labels" . | nindent 4 }} 11 | spec: 12 | endpoints: 13 | - port: {{ .Values.service.portName }} 14 | {{- with .Values.serviceMonitor.interval }} 15 | interval: {{ . | quote }} 16 | {{- end }} 17 | {{- with .Values.serviceMonitor.scrapeTimeout }} 18 | scrapeTimeout: {{ . }} 19 | {{- end }} 20 | path: /metrics 21 | {{- with .Values.serviceMonitor.relabelings }} 22 | relabelings: 23 | {{- toYaml . | nindent 8 }} 24 | {{- end }} 25 | {{- with .Values.serviceMonitor.metricRelabelings }} 26 | metricRelabelings: 27 | {{- toYaml . | nindent 8 }} 28 | {{- end }} 29 | namespaceSelector: 30 | matchNames: 31 | - {{ .Release.Namespace }} 32 | selector: 33 | matchLabels: 34 | {{- include "fritz-exporter.selectorLabels" . | nindent 6 }} 35 | {{- end }} 36 | -------------------------------------------------------------------------------- /fritzexporter/config/__init__.py: -------------------------------------------------------------------------------- 1 | from .config import DeviceConfig, ExporterConfig, get_config 2 | from .exceptions import ( 3 | ConfigError, 4 | ConfigFileUnreadableError, 5 | DeviceNamesNotUniqueError, 6 | EmptyConfigError, 7 | ExporterError, 8 | FritzPasswordFileDoesNotExistError, 9 | FritzPasswordTooLongError, 10 | NoDevicesFoundError, 11 | ) 12 | 13 | __all__ = [ 14 | "ExporterError", 15 | "ConfigError", 16 | "EmptyConfigError", 17 | "ConfigFileUnreadableError", 18 | "DeviceNamesNotUniqueError", 19 | "NoDevicesFoundError", 20 | "FritzPasswordTooLongError", 21 | "FritzPasswordFileDoesNotExistError", 22 | "ExporterConfig", 23 | "DeviceConfig", 24 | "get_config", 25 | ] 26 | 27 | # Copyright 2019-2025 Patrick Dreker 28 | # 29 | # Licensed under the Apache License, Version 2.0 (the "License"); 30 | # you may not use this file except in compliance with the License. 31 | # You may obtain a copy of the License at 32 | # 33 | # http://www.apache.org/licenses/LICENSE-2.0 34 | # 35 | # Unless required by applicable law or agreed to in writing, software 36 | # distributed under the License is distributed on an "AS IS" BASIS, 37 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 38 | # See the License for the specific language governing permissions and 39 | # limitations under the License. 40 | -------------------------------------------------------------------------------- /.github/workflows/build-trunk.yaml: -------------------------------------------------------------------------------- 1 | name: Build and Push Trunk 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | jobs: 8 | build-trunk: 9 | runs-on: ubuntu-latest 10 | name: Build Trunk 11 | steps: 12 | # Prepare Environment 13 | - uses: actions/checkout@v6 14 | with: 15 | token: ${{ secrets.MASTER_PUSH_TOKEN }} 16 | fetch-depth: 0 17 | - name: Login to Registry 18 | uses: docker/login-action@v3 19 | with: 20 | registry: ${{ vars.DOCKER_REGISTRY_HOST }} 21 | username: ${{ vars.DOCKER_REGISTRY_USERNAME }} 22 | password: ${{ secrets.DOCKER_HUB }} 23 | 24 | - name: generate docker tags 25 | id: meta 26 | uses: docker/metadata-action@v5 27 | with: 28 | images: | 29 | ${{ vars.DOCKER_REGISTRY_HOST }}/${{ vars.DOCKER_REGISTRY_REPO }}/${{ vars.DOCKER_REGISTRY_IMAGE }} 30 | tags: | 31 | type=raw,value=develop 32 | # Build Docker Images (amd64 and arm64) 33 | - name: Set up QEMU 34 | uses: docker/setup-qemu-action@v3 35 | - name: Set up Docker Buildx 36 | uses: docker/setup-buildx-action@v3 37 | - name: Build and push 38 | uses: docker/build-push-action@v6 39 | with: 40 | push: true 41 | context: . 42 | platforms: linux/amd64,linux/aarch64 43 | tags: ${{ steps.meta.outputs.tags }} 44 | -------------------------------------------------------------------------------- /docs/upgrading.rst: -------------------------------------------------------------------------------- 1 | Upgrade Notes (potentially breaking changes) 2 | ============================================ 3 | 4 | v2.0.0 5 | ------ 6 | 7 | Label changes 8 | ^^^^^^^^^^^^^ 9 | 10 | Version 2.0.0 changes the ``direction`` labels of some metrics to consistently use ``tx`` (transmitted, upstream) and ``rx`` (received, downstream). Before this change the labels were ``up`` and ``down`` respectively, while other metrics used ``tx`` and ``rx``. 11 | 12 | Affected metrics: 13 | 14 | * fritz_wan_data 15 | * fritz_wan_data_packets 16 | 17 | Config changes 18 | ^^^^^^^^^^^^^^ 19 | 20 | Multi device configuration was dropped from the environment configuration. The ``FRITZ_EXPORTER_CONFIG`` environment variable was removed completely. When using environment configuration this exporter now only supports a single device. For multi device support please use the new config file option. 21 | 22 | WiFi metrics changes 23 | ^^^^^^^^^^^^^^^^^^^^ 24 | 25 | All WiFi metrics have been merged. So e.g. ``fritz_wifi_2_4GHz_*`` is changed to ``fritz_wifi_*`` and two labels (``wifi_index`` and ``wifi_name``) are added to the metrics. 26 | 27 | v1.0.0 28 | ------ 29 | 30 | Version 1.0.0 of the exporter has completely reworked how this exports metrics! If you have used this exporter in the past, you will get a completely new set of metrics. 31 | 32 | * the metrics prefix has changed from ``fritzbox_`` to ``fritz_`` 33 | * all labels have been converted to lower-case (e.g. ``Serial`` -> ``serial``) to be in line with the common usage of labels 34 | * some metrics have been renamed to better reflect their content 35 | -------------------------------------------------------------------------------- /fritzexporter/config/exceptions.py: -------------------------------------------------------------------------------- 1 | class ExporterError(Exception): 2 | def __init__(self, *args: object) -> None: 3 | super().__init__(*args) 4 | 5 | 6 | class ConfigError(ExporterError): 7 | pass 8 | 9 | 10 | class EmptyConfigError(ExporterError): 11 | pass 12 | 13 | 14 | class ConfigFileUnreadableError(ExporterError): 15 | pass 16 | 17 | 18 | class DeviceNamesNotUniqueError(ExporterError): 19 | pass 20 | 21 | 22 | class NoDevicesFoundError(ExporterError): 23 | pass 24 | 25 | 26 | class FritzPasswordTooLongError(ExporterError): 27 | def __init__(self) -> None: 28 | super().__init__( 29 | "Password is longer than 32 characters! " 30 | "Login may not succeed, please see documentation!" 31 | ) 32 | 33 | 34 | class FritzPasswordFileDoesNotExistError(ExporterError): 35 | def __init__(self) -> None: 36 | super().__init__("Password file does not exist!") 37 | 38 | 39 | # Copyright 2019-2025 Patrick Dreker 40 | # 41 | # Licensed under the Apache License, Version 2.0 (the "License"); 42 | # you may not use this file except in compliance with the License. 43 | # You may obtain a copy of the License at 44 | # 45 | # http://www.apache.org/licenses/LICENSE-2.0 46 | # 47 | # Unless required by applicable law or agreed to in writing, software 48 | # distributed under the License is distributed on an "AS IS" BASIS, 49 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 50 | # See the License for the specific language governing permissions and 51 | # limitations under the License. 52 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yaml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | push: 7 | branches: [main] 8 | 9 | jobs: 10 | run-checks: 11 | runs-on: ubuntu-latest 12 | name: Run Unit Tests 13 | steps: 14 | - uses: actions/checkout@v6 15 | with: 16 | fetch-depth: 0 17 | - name: Set up Python 18 | uses: actions/setup-python@v6 19 | with: 20 | python-version: ${{ vars.PYTHON_VERSION }} 21 | - name: Setup poetry 22 | uses: abatilo/actions-poetry@v4 23 | with: 24 | poetry-version: ${{ vars.POETRY_VERSION }} 25 | - name: Install dependencies 26 | run: | 27 | python -m pip install --upgrade pip 28 | poetry install 29 | - name: Lint with flake8 30 | id: flake8 31 | continue-on-error: true 32 | run: | 33 | poetry run flake8 . | tee lint.log 34 | rescode=${PIPESTATUS[0]} 35 | echo "::set-output name=lint::$(cat lint.log)" 36 | exit $rescode 37 | - name: Run pytest 38 | id: pytest 39 | continue-on-error: true 40 | run: | 41 | poetry run python -m pytest --cov=. --cov-report=xml --cov-config=.coveragerc . | tee test.log 42 | rescode=${PIPESTATUS[0]} 43 | echo "::set-output name=tests::$(cat test.log)" 44 | exit $rescode 45 | - name: SonarCloud Scan 46 | uses: SonarSource/sonarqube-scan-action@master 47 | env: 48 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 49 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 50 | -------------------------------------------------------------------------------- /fritzexporter/action_blacklists.py: -------------------------------------------------------------------------------- 1 | from typing import NamedTuple 2 | 3 | 4 | class BlacklistItem(NamedTuple): 5 | service: str 6 | action: str 7 | return_value: str | None = None 8 | 9 | 10 | call_blacklist = [ 11 | BlacklistItem("DeviceConfig1", "GetPersistentData"), 12 | BlacklistItem("DeviceConfig1", "X_AVM-DE_GetConfigFile"), 13 | BlacklistItem("Hosts1", "X_AVM-DE_GetAutoWakeOnLANByMACAddress"), 14 | BlacklistItem("WANCommonInterfaceConfig1", "X_AVM-DE_GetOnlineMonitor"), 15 | BlacklistItem("WLANConfiguration1", "GetDefaultWEPKeyIndex"), 16 | BlacklistItem("WLANConfiguration2", "GetDefaultWEPKeyIndex"), 17 | BlacklistItem("WLANConfiguration3", "GetDefaultWEPKeyIndex"), 18 | BlacklistItem("WLANConfiguration4", "GetDefaultWEPKeyIndex"), 19 | BlacklistItem("X_AVM-DE_AppSetup1", "GetAppMessageFilter"), 20 | BlacklistItem("X_AVM-DE_Filelinks1", "GetNumberOfFilelinkEntries"), 21 | BlacklistItem("X_AVM-DE_HostFilter1", "GetTicketIDStatus"), 22 | BlacklistItem("X_AVM-DE_OnTel1", "GetCallBarringEntry"), 23 | BlacklistItem("X_AVM-DE_OnTel1", "GetCallBarringEntryByNum"), 24 | BlacklistItem("X_AVM-DE_OnTel1", "GetDeflection"), 25 | BlacklistItem("X_AVM-DE_OnTel1", "GetPhonebook"), 26 | BlacklistItem("X_AVM-DE_OnTel1", "GetPhonebookEntry"), 27 | BlacklistItem("X_AVM-DE_OnTel1", "GetPhonebookEntryUID"), 28 | BlacklistItem("X_AVM-DE_TAM1", "GetInfo"), 29 | BlacklistItem("X_VoIP1", "GetVoIPEnableAreaCode"), 30 | BlacklistItem("X_VoIP1", "GetVoIPEnableCountryCode"), 31 | BlacklistItem("X_VoIP1", "X_AVM-DE_GetClient"), 32 | BlacklistItem("X_VoIP1", "X_AVM-DE_GetClient2"), 33 | BlacklistItem("X_VoIP1", "X_AVM-DE_GetClient3"), 34 | BlacklistItem("X_VoIP1", "X_AVM-DE_GetClientByClientId"), 35 | BlacklistItem("X_VoIP1", "X_AVM-DE_GetPhonePort"), 36 | BlacklistItem("X_VoIP1", "X_AVM-DE_GetVoIPAccount"), 37 | ] 38 | -------------------------------------------------------------------------------- /helm/fritz-exporter/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Expand the name of the chart. 3 | */}} 4 | {{- define "fritz-exporter.name" -}} 5 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 6 | {{- end }} 7 | 8 | {{/* 9 | Create a default fully qualified app name. 10 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 11 | If release name contains chart name it will be used as a full name. 12 | */}} 13 | {{- define "fritz-exporter.fullname" -}} 14 | {{- if .Values.fullnameOverride }} 15 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} 16 | {{- else }} 17 | {{- $name := default .Chart.Name .Values.nameOverride }} 18 | {{- if contains $name .Release.Name }} 19 | {{- .Release.Name | trunc 63 | trimSuffix "-" }} 20 | {{- else }} 21 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} 22 | {{- end }} 23 | {{- end }} 24 | {{- end }} 25 | 26 | {{/* 27 | Create chart name and version as used by the chart label. 28 | */}} 29 | {{- define "fritz-exporter.chart" -}} 30 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 31 | {{- end }} 32 | 33 | {{/* 34 | Common labels 35 | */}} 36 | {{- define "fritz-exporter.labels" -}} 37 | helm.sh/chart: {{ include "fritz-exporter.chart" . }} 38 | {{ include "fritz-exporter.selectorLabels" . }} 39 | {{- if .Chart.AppVersion }} 40 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 41 | {{- end }} 42 | app.kubernetes.io/managed-by: {{ .Release.Service }} 43 | {{- end }} 44 | 45 | {{/* 46 | Selector labels 47 | */}} 48 | {{- define "fritz-exporter.selectorLabels" -}} 49 | app.kubernetes.io/name: {{ include "fritz-exporter.name" . }} 50 | app.kubernetes.io/instance: {{ .Release.Name }} 51 | {{- end }} 52 | 53 | {{/* 54 | Create the name of the service account to use 55 | */}} 56 | {{- define "fritz-exporter.serviceAccountName" -}} 57 | {{- if .Values.serviceAccount.create }} 58 | {{- default (include "fritz-exporter.fullname" .) .Values.serviceAccount.name }} 59 | {{- else }} 60 | {{- default "default" .Values.serviceAccount.name }} 61 | {{- end }} 62 | {{- end }} 63 | -------------------------------------------------------------------------------- /docs/coding.rst: -------------------------------------------------------------------------------- 1 | Code Documentation 2 | ================== 3 | 4 | General structure 5 | ----------------- 6 | 7 | fritz_exporter uses some "black magic" (this may be clever, but it makes understanding what's going on a lot harder...) to manage capabilities of devices. This section will try to make some sense of this code... At least for the author... 8 | 9 | Top Level and Entry Point 10 | ------------------------- 11 | 12 | The Top-Level is formed by a single ``FritzCollector`` object. 13 | 14 | ``FritzCollector`` holds an attribute ``self.capabilities`` of type ``FritzCapabilities``. ``FritzCapabilities`` is an iterable, dict-like object which holds a collection of ``FritzCapability`` (note plural vs. singular...). 15 | 16 | When ``FritzCapabilities`` is intsnatiated it can be done, with or without a ``FritzDevice``. If done without a device, the resulting object will simply be a list of all capabilities. If done **with** a device, the resulting full list of capabilities will be checked against what the device actually supports and unsupported capabilities will be removed from the list. 17 | 18 | When a device definition is found in the config, this will instantiate a ``FritzDevice``object and register it to the ``FritzCollector``. 19 | 20 | ``FritzCapability`` is an abstract baseclass (ABC) for the concrete capabilities. The subclasses automatically register to the baseclass using ``cls.__init_subclass__()``. 21 | 22 | In the end the ``FritzCollector`` will have a ``FritzCapabilities`` representing the union of all supported capabilities of the devices. When ``FritzCollector.collect()``is called it will use this list of generally available capabilities to fetch the metrics from the ``FritzCapability`` baseclass, which will in turn call this for each individual device. 23 | 24 | Yes, this is convoluted 25 | 26 | tl;dr 27 | ----- 28 | 29 | ``FritzCollector`` has a list of ``FritzDevice``s and a ``FritzCapabilities`` collection of ``FritzCapability`` subclasses, which represent the union of all capabilities which are available on any registered device. 30 | 31 | Each ``FritzDevice`` again has a ``FritzCapabilities`` collection representing its own supported capabilities which are then used to actually collect metrics. 32 | -------------------------------------------------------------------------------- /fritz_export_helper.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import json 3 | from pprint import pprint 4 | 5 | from fritzconnection import FritzConnection 6 | from fritzconnection.core.exceptions import ActionError, FritzInternalError, ServiceError 7 | 8 | parser = argparse.ArgumentParser( 9 | description="Check actions against Fritz TR-064 and AHA-HTTP API and pretty print the results." 10 | ) 11 | parser.add_argument("fritz_ip", help="Fritz Device IP Address") 12 | parser.add_argument("username", help="Username of a user on the Fritz device.") 13 | parser.add_argument("password", help="Password of the user on the Fritz device.") 14 | parser.add_argument( 15 | "-m", 16 | "--mode", 17 | choices=["tr064", "http"], 18 | default="tr064", 19 | help="Tell the helper which API to use (default: TR-064).", 20 | ) 21 | parser.add_argument("-s", "--service", help="Service to call (only for TR-064).") 22 | parser.add_argument("-a", "--action", help="Action to call.") 23 | parser.add_argument("-i", "--ain", help="AIN of the device (only for HTTP).") 24 | parser.add_argument( 25 | "-j", 26 | "--action_args", 27 | nargs="?", 28 | help="Optional arguments (as JSON dict string) to call (only for TR-064).", 29 | ) 30 | 31 | args = parser.parse_args() 32 | 33 | fc = FritzConnection(address=args.fritz_ip, user=args.username, password=args.password) 34 | 35 | try: 36 | if args.mode == "http": 37 | if args.ain: 38 | if args.action is None: 39 | result = fc.call_http("getdeviceinfos", args.ain) 40 | else: 41 | result = fc.call_http(args.action, args.ain) 42 | else: 43 | args.action = "getdevicelistinfos" 44 | result = fc.call_http(args.action) 45 | else: # noqa: PLR5501 46 | if args.action_args: 47 | arguments = json.loads(args.action_args) 48 | result = fc.call_action(args.service, args.action, arguments=arguments) 49 | else: 50 | result = fc.call_action(args.service, args.action) 51 | print("--------------------------------\nRESULT:") # noqa: T201 52 | pprint(result) # noqa: T203 53 | except (ServiceError, ActionError, FritzInternalError) as e: 54 | print(f"Calling service {args.service} with action {args.action} returned an error: {e}") # noqa: T201 55 | -------------------------------------------------------------------------------- /helm/fritz-exporter/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "fritz-exporter.fullname" . }} 5 | labels: 6 | {{- include "fritz-exporter.labels" . | nindent 4 }} 7 | spec: 8 | replicas: {{ .Values.replicaCount }} 9 | selector: 10 | matchLabels: 11 | {{- include "fritz-exporter.selectorLabels" . | nindent 6 }} 12 | template: 13 | metadata: 14 | {{- with .Values.podAnnotations }} 15 | annotations: 16 | {{- toYaml . | nindent 8 }} 17 | {{- end }} 18 | labels: 19 | {{- include "fritz-exporter.selectorLabels" . | nindent 8 }} 20 | spec: 21 | {{- with .Values.imagePullSecrets }} 22 | imagePullSecrets: 23 | {{- toYaml . | nindent 8 }} 24 | {{- end }} 25 | serviceAccountName: {{ include "fritz-exporter.serviceAccountName" . }} 26 | securityContext: 27 | {{- toYaml .Values.podSecurityContext | nindent 8 }} 28 | containers: 29 | - name: {{ .Chart.Name }} 30 | workingDir: /app 31 | args: 32 | - "--config" 33 | - "/etc/fritz/config.yaml" 34 | securityContext: 35 | {{- toYaml .Values.securityContext | nindent 12 }} 36 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" 37 | imagePullPolicy: {{ .Values.image.pullPolicy }} 38 | ports: 39 | - name: metrics 40 | containerPort: 9787 41 | protocol: TCP 42 | resources: 43 | {{- toYaml .Values.resources | nindent 12 }} 44 | volumeMounts: 45 | - name: config 46 | mountPath: "/etc/fritz" 47 | readOnly: true 48 | volumes: 49 | - name: config 50 | secret: 51 | secretName: {{ include "fritz-exporter.fullname" . }}-config 52 | items: 53 | - key: "config.yaml" 54 | path: "config.yaml" 55 | {{- with .Values.nodeSelector }} 56 | nodeSelector: 57 | {{- toYaml . | nindent 8 }} 58 | {{- end }} 59 | {{- with .Values.affinity }} 60 | affinity: 61 | {{- toYaml . | nindent 8 }} 62 | {{- end }} 63 | {{- with .Values.tolerations }} 64 | tolerations: 65 | {{- toYaml . | nindent 8 }} 66 | {{- end }} 67 | -------------------------------------------------------------------------------- /helm/fritz-exporter/values.yaml: -------------------------------------------------------------------------------- 1 | # Default values for fritz-exporter. 2 | # This is a YAML-formatted file. 3 | # Declare variables to be passed into your templates. 4 | 5 | # Use this to specify your config file. You can just put the YAMl for the config here. 6 | config: {} 7 | # exporter_port: 9787 # optional 8 | # log_level: DEBUG # optional 9 | # devices: 10 | # - name: Fritz!Box 7590 Router # optional 11 | # hostname: fritz.box 12 | # username: prometheus 13 | # password: prometheus 14 | # host_info: True 15 | # - name: Repeater Wohnzimmer # optional 16 | # hostname: repeater-Wohnzimmer 17 | # username: prometheus 18 | # password: prometheus 19 | 20 | replicaCount: 1 21 | 22 | image: 23 | repository: pdreker/fritz_exporter 24 | pullPolicy: IfNotPresent 25 | # Overrides the image tag whose default is the chart appVersion. 26 | tag: "" 27 | 28 | imagePullSecrets: [] 29 | nameOverride: "" 30 | fullnameOverride: "" 31 | 32 | serviceMonitor: 33 | enabled: true 34 | namespace: "" 35 | interval: "60s" 36 | scrapeTimeout: "30s" 37 | relabelings: "" 38 | metricRelabelings: "" 39 | 40 | serviceAccount: 41 | # Specifies whether a service account should be created 42 | create: true 43 | # Annotations to add to the service account 44 | annotations: {} 45 | # The name of the service account to use. 46 | # If not set and create is true, a name is generated using the fullname template 47 | name: "" 48 | 49 | podAnnotations: {} 50 | 51 | podSecurityContext: 52 | {} 53 | # fsGroup: 2000 54 | 55 | securityContext: 56 | # capabilities: 57 | # drop: 58 | # - ALL 59 | # readOnlyRootFilesystem: true 60 | runAsNonRoot: true 61 | runAsUser: 65534 62 | 63 | service: 64 | type: ClusterIP 65 | port: 9787 66 | portName: metrics 67 | 68 | resources: 69 | {} 70 | # We usually recommend not to specify default resources and to leave this as a conscious 71 | # choice for the user. This also increases chances charts run on environments with little 72 | # resources, such as Minikube. If you do want to specify resources, uncomment the following 73 | # lines, adjust them as necessary, and remove the curly braces after 'resources:'. 74 | # limits: 75 | # cpu: 100m 76 | # memory: 128Mi 77 | # requests: 78 | # cpu: 100m 79 | # memory: 128Mi 80 | 81 | nodeSelector: {} 82 | 83 | tolerations: [] 84 | 85 | affinity: {} 86 | -------------------------------------------------------------------------------- /docs/docker-images.rst: -------------------------------------------------------------------------------- 1 | Docker images 2 | ============= 3 | 4 | Docker images for this exporter are available from `Docker Hub `_. The images are built for amd64, arm6, arm7 and arm8/64, so they should run on almost all relevant platforms (Intel, Raspberry Pi (basically any version) and Apple Silicon). 5 | 6 | Tags 7 | ---- 8 | 9 | +----------------+----------------------------------------------------------------------------------+ 10 | | Tag | Description | 11 | +================+==================================================================================+ 12 | | develop | Latest build from develop branch. **This may be unstable and change at any | 13 | | | time without notice or regards for compatibility!** | 14 | +----------------+----------------------------------------------------------------------------------+ 15 | | latest | Latest released version. This might automatically upgrade through major | 16 | | | releases, which may be incompatible with currently running versions. | 17 | | | Can be used, if you don't mind the occasional breakage. Major releases are rare. | 18 | +----------------+----------------------------------------------------------------------------------+ 19 | | full version | Specific released versions. Will not update your images without you explicity | 20 | | (e.g. | changing the image tag in Docker. | 21 | | 2.1.1) | | 22 | +----------------+----------------------------------------------------------------------------------+ 23 | | major | Specific major version. E.g. "2" will install any 2.x.y release thus | 24 | | (e.g. "2") | avoiding unexpected major upgrades which may be incompatible or contain | 25 | | | breaking changes. **Recommended** | 26 | +----------------+----------------------------------------------------------------------------------+ 27 | | major.minor | Specific major/minor version. E.g. "2.1" will install the latest 2.1.x release | 28 | | (e.g. 2.1) | thus avoiding unexpected major upgrades which may be incompatible or contain | 29 | | | breaking changes. This will effectively only update patch releases. | 30 | +----------------+----------------------------------------------------------------------------------+ 31 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | 6 | name: release-please 7 | jobs: 8 | release: 9 | runs-on: ubuntu-latest 10 | outputs: 11 | release_created: ${{ steps.release.outputs.release_created }} 12 | major: ${{ steps.release.outputs.major }} 13 | minor: ${{ steps.release.outputs.minor }} 14 | patch: ${{ steps.release.outputs.patch }} 15 | steps: 16 | - uses: googleapis/release-please-action@v4 17 | id: release 18 | with: 19 | token: ${{ secrets.MASTER_PUSH_TOKEN }} 20 | 21 | build-docker-release: 22 | needs: release 23 | if: needs.release.outputs.release_created 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v6 27 | - name: Login to Registry 28 | uses: docker/login-action@v3 29 | with: 30 | registry: ${{ vars.DOCKER_REGISTRY_HOST }} 31 | username: ${{ vars.DOCKER_REGISTRY_USERNAME }} 32 | password: ${{ secrets.DOCKER_HUB }} 33 | 34 | - name: generate docker tags 35 | id: meta 36 | uses: docker/metadata-action@v5 37 | with: 38 | images: | 39 | ${{ vars.DOCKER_REGISTRY_HOST }}/${{ vars.DOCKER_REGISTRY_REPO }}/${{ vars.DOCKER_REGISTRY_IMAGE }} 40 | tags: | 41 | type=semver,pattern={{version}},value=${{ needs.release.outputs.major }}.${{ needs.release.outputs.minor }}.${{ needs.release.outputs.patch }} 42 | type=semver,pattern={{major}}.{{minor}},value=${{ needs.release.outputs.major }}.${{ needs.release.outputs.minor }}.${{ needs.release.outputs.patch }} 43 | type=semver,pattern={{major}},value=${{ needs.release.outputs.major }}.${{ needs.release.outputs.minor }}.${{ needs.release.outputs.patch }} 44 | # Build Docker Images (amd64 and arm64) 45 | - name: Set up QEMU 46 | uses: docker/setup-qemu-action@v3 47 | - name: Set up Docker Buildx 48 | uses: docker/setup-buildx-action@v3 49 | - name: Build and push 50 | uses: docker/build-push-action@v6 51 | with: 52 | push: true 53 | context: . 54 | platforms: linux/arm/v6,linux/arm/v7,linux/amd64,linux/aarch64 55 | tags: ${{ steps.meta.outputs.tags }} 56 | 57 | build-pypi-release: 58 | needs: release 59 | if: needs.release.outputs.release_created 60 | runs-on: ubuntu-latest 61 | steps: 62 | - uses: actions/checkout@v6 63 | - uses: actions/setup-python@v6 64 | with: 65 | python-version: ${{ vars.PYTHON_VERSION }} 66 | - name: Setup poetry 67 | uses: abatilo/actions-poetry@v4 68 | with: 69 | poetry-version: ${{ vars.POETRY_VERSION }} 70 | - name: setup poetry 71 | run: poetry config pypi-token.pypi ${{ secrets.PYPI_TOKEN }} 72 | - name: build and publish to PyPI 73 | run: poetry --build --no-interaction publish 74 | -------------------------------------------------------------------------------- /docs/building.rst: -------------------------------------------------------------------------------- 1 | Building 2 | ======== 3 | 4 | The recommended way to run this exporter is from a docker container. The included Dockerfile will build the exporter using an python:alpine container using python3. The Dockerfile relies on a locally generated ``requirements.txt`` file and the builds a clean python:3-alpine image with the exporter. 5 | 6 | Building and running from a local image 7 | --------------------------------------- 8 | 9 | To build clone the repository from `GitHub `_, enter the repository and execute 10 | 11 | .. code-block:: bash 12 | 13 | docker build -t fritz_exporter:local . 14 | 15 | 16 | from inside the source directory. 17 | 18 | To run the resulting image use 19 | 20 | .. code-block:: bash 21 | 22 | docker run -d --name fritz_exporter -p : -e FRITZ_EXPORTER_CONFIG="192.168.178.1,username,password" fritz_exporter:local 23 | 24 | 25 | Verify correct operation 26 | ^^^^^^^^^^^^^^^^^^^^^^^^ 27 | 28 | To verify correct operation just use curl against the running exporter. It should reply with a Prometheus-style list of metrics: 29 | 30 | .. code-block:: bash 31 | 32 | > curl localhost: 33 | 34 | # HELP python_gc_objects_collected_total Objects collected during gc 35 | # TYPE python_gc_objects_collected_total counter 36 | python_gc_objects_collected_total{generation="0"} 481.0 37 | python_gc_objects_collected_total{generation="1"} 112.0 38 | python_gc_objects_collected_total{generation="2"} 0.0 39 | # HELP python_gc_objects_uncollectable_total Uncollectable object found during GC 40 | # TYPE python_gc_objects_uncollectable_total counter 41 | python_gc_objects_uncollectable_total{generation="0"} 0.0 42 | python_gc_objects_uncollectable_total{generation="1"} 0.0 43 | python_gc_objects_uncollectable_total{generation="2"} 0.0 44 | ... 45 | 46 | .. note:: 47 | 48 | If you have the ``host_info`` config enabled for one or more devices please be aware, that it may take a long time to receive the reply, as the metrics are read sequentially when queried. Practical experience has shown that a device knowing around 70 WiFi devices may take 20-30s to reply to all metrics queries. So set appropriate timeouts. 49 | 50 | Building and running locally (no containers) 51 | -------------------------------------------- 52 | 53 | You can install the latest release from PyPI using ``pip install fritzexporter`` and the run it using ``python -m fritzexporter``. It is highly recommended to use a virtual environment to do so. 54 | 55 | For development and debugging it may be neccessary or simpler to run the exporter directly without docker. To do this simply install the dependencies into a virtual environment using ``poetry install``. You can then enter the venv using ``poetry shell``. 56 | 57 | To run the exporter just use ``python -m fritzexporter --config /path/to/config.yaml`` or set environment variables as described in :ref:`environment-config`. 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | docs/docker-compose.secret.yml 2 | fritz-mixin/vendor/** 3 | requirements.txt 4 | .DS_Store 5 | 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | share/python-wheels/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | MANIFEST 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .nox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | *.py,cover 55 | .hypothesis/ 56 | .pytest_cache/ 57 | cover/ 58 | 59 | # Translations 60 | *.mo 61 | *.pot 62 | 63 | # Django stuff: 64 | *.log 65 | local_settings.py 66 | db.sqlite3 67 | db.sqlite3-journal 68 | 69 | # Flask stuff: 70 | instance/ 71 | .webassets-cache 72 | 73 | # Scrapy stuff: 74 | .scrapy 75 | 76 | # Sphinx documentation 77 | docs/_build/ 78 | 79 | # PyBuilder 80 | .pybuilder/ 81 | target/ 82 | 83 | # Jupyter Notebook 84 | .ipynb_checkpoints 85 | 86 | # IPython 87 | profile_default/ 88 | ipython_config.py 89 | 90 | # pyenv 91 | # For a library or package, you might want to ignore these files since the code is 92 | # intended to run in multiple environments; otherwise, check them in: 93 | # .python-version 94 | 95 | # pipenv 96 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 97 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 98 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 99 | # install all needed dependencies. 100 | #Pipfile.lock 101 | 102 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 103 | __pypackages__/ 104 | 105 | # Celery stuff 106 | celerybeat-schedule 107 | celerybeat.pid 108 | 109 | # SageMath parsed files 110 | *.sage.py 111 | 112 | # Environments 113 | .env 114 | .venv 115 | env/ 116 | venv/ 117 | ENV/ 118 | env.bak/ 119 | venv.bak/ 120 | 121 | # Spyder project settings 122 | .spyderproject 123 | .spyproject 124 | 125 | # Rope project settings 126 | .ropeproject 127 | 128 | # mkdocs documentation 129 | /site 130 | 131 | # mypy 132 | .mypy_cache/ 133 | .dmypy.json 134 | dmypy.json 135 | 136 | # Pyre type checker 137 | .pyre/ 138 | 139 | # pytype static type analyzer 140 | .pytype/ 141 | 142 | # Cython debug symbols 143 | cython_debug/ 144 | 145 | .vscode/* 146 | !.vscode/settings.json 147 | !.vscode/tasks.json 148 | !.vscode/launch.json 149 | !.vscode/extensions.json 150 | *.code-workspace 151 | 152 | # Local History for Visual Studio Code 153 | .history/ 154 | helm/fritz-exporter/values-orbital.yaml 155 | donation.txt 156 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from unittest.mock import MagicMock, call, patch 3 | 4 | import pytest 5 | 6 | from fritzexporter.__main__ import main, parse_cmdline 7 | 8 | from .fc_services_mock import call_action_mock, create_fc_services, fc_services_devices 9 | 10 | 11 | class Test_Main: 12 | def test_cli_args_log_level(self, monkeypatch): 13 | monkeypatch.setattr("sys.argv", ["fritzexporter", "--log-level", "DEBUG"]) 14 | 15 | args = parse_cmdline() 16 | 17 | assert "log_level" in args 18 | assert args.log_level == "DEBUG" 19 | 20 | def test_cli_args_config(self, monkeypatch): 21 | monkeypatch.setattr("sys.argv", ["fritzexporter", "--config", "/some/file.yaml"]) 22 | 23 | args = parse_cmdline() 24 | 25 | assert "config" in args 26 | assert args.config == "/some/file.yaml" 27 | 28 | def test_cli_args_donate_data(self, monkeypatch): 29 | monkeypatch.setattr("sys.argv", ["fritzexporter", "--donate-data"]) 30 | 31 | args = parse_cmdline() 32 | 33 | assert "donate_data" in args 34 | 35 | def test_cli_args_upload_data(self, monkeypatch): 36 | monkeypatch.setattr("sys.argv", ["fritzexporter", "--upload-data"]) 37 | 38 | args = parse_cmdline() 39 | 40 | assert "donate_data" in args 41 | 42 | def test_cli_args_sanitize(self, monkeypatch): 43 | monkeypatch.setattr( 44 | "sys.argv", 45 | ["fritzexporter", "--sanitize", "FOO", "BAR", "-s", "FOOBAR", "BLABLA", "SOMETHING"], 46 | ) 47 | 48 | args = parse_cmdline() 49 | 50 | assert "sanitize" in args 51 | assert args.sanitize == [["FOO", "BAR"], ["FOOBAR", "BLABLA", "SOMETHING"]] 52 | 53 | def test_cli_args_version(self, monkeypatch): 54 | monkeypatch.setattr("sys.argv", ["fritzexporter", "--version"]) 55 | 56 | args = parse_cmdline() 57 | 58 | assert "version" in args 59 | 60 | def test_if_version_prints_version_and_stops(self, monkeypatch, capsys): 61 | monkeypatch.setattr("sys.argv", ["fritzexporter", "--version"]) 62 | 63 | with pytest.raises(SystemExit) as pytest_wrapped_exit: 64 | main() 65 | 66 | captured = capsys.readouterr() 67 | assert captured.out == "develop\n" 68 | assert captured.err == "" 69 | assert pytest_wrapped_exit.type == SystemExit 70 | assert pytest_wrapped_exit.value.code == 0 71 | 72 | def test_invalid_config_exits_with_code(self, monkeypatch, caplog): 73 | monkeypatch.setattr("sys.argv", ["fritzexporter", "--config", "/does/not/exist"]) 74 | 75 | with pytest.raises(SystemExit) as pytest_wrapped_exit: 76 | main() 77 | 78 | assert pytest_wrapped_exit.type == SystemExit 79 | assert pytest_wrapped_exit.value.code == 1 80 | assert "fritzexporter.config.exceptions.ConfigFileUnreadableError" in caplog.text 81 | 82 | @patch("fritzexporter.fritzdevice.FritzConnection") 83 | def test_valid_args_run_clean(self, mock_fc: MagicMock, monkeypatch, caplog): 84 | monkeypatch.setattr( 85 | "sys.argv", 86 | [ 87 | "fritzexporter", 88 | "--config", 89 | "tests/conffiles/validconfig.yaml", 90 | "--log-level", 91 | "DEBUG", 92 | ], 93 | ) 94 | monkeypatch.setenv("FRITZ_EXPORTER_UNDER_TEST", "true") # do not enter infinite loop 95 | 96 | # Prepare 97 | caplog.set_level(logging.DEBUG) 98 | 99 | fc = mock_fc.return_value 100 | fc.call_action.side_effect = call_action_mock 101 | fc.services = create_fc_services(fc_services_devices["FritzBox 7590"]) 102 | 103 | main() 104 | 105 | loggers = [logging.getLogger(name) for name in logging.root.manager.loggerDict] 106 | for log in loggers: 107 | if log.name.startswith("fritzexporter"): 108 | assert log.level == logging.DEBUG 109 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fritz! exporter for prometheus 2 | 3 | [![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=pdreker_fritz_exporter&metric=reliability_rating)](https://sonarcloud.io/summary/new_code?id=pdreker_fritz_exporter) [![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=pdreker_fritz_exporter&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=pdreker_fritz_exporter) [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=pdreker_fritz_exporter&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=pdreker_fritz_exporter) [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=pdreker_fritz_exporter&metric=coverage)](https://sonarcloud.io/summary/new_code?id=pdreker_fritz_exporter) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=pdreker_fritz_exporter&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=pdreker_fritz_exporter) 4 | 5 | ![ReadTheDocs](https://readthedocs.org/projects/docs/badge/?version=latest) ![Dependabot](https://img.shields.io/badge/dependabot-025E8C?style=flat&logo=dependabot&logoColor=white) ![Tests](https://img.shields.io/github/actions/workflow/status/pdreker/fritz_exporter/run-tests.yaml?label=Tests) ![Build](https://img.shields.io/github/actions/workflow/status/pdreker/fritz_exporter/build-trunk.yaml?branch=main) 6 | 7 | This is a prometheus exporter for AVM Fritz! home network devices commonly found in Europe. This exporter uses the devices builtin TR-064 API via the fritzconnection python module. 8 | 9 | The exporter should work with Fritz!Box and Fritz!Repeater Devices (and maybe others). It actively checks for supported metrics and queries the for all devices configured (Yes, it has multi-device support for all you Mesh users out there.) 10 | 11 | It has been tested against an AVM Fritz!Box 7590 (DSL), a Fritz!Repeater 2400 and a Fritz!WLAN Repeater 1750E. If you have another box and data is missing, please file an issue or PR on GitHub. 12 | 13 | ## Documentation 14 | 15 | Check out the full documentation at [ReadTheDocs](https://fritz-exporter.readthedocs.io/) 16 | 17 | ## Attention - Prometheus required 18 | 19 | As the scope of this exporter lies on a typical home device, this also means that there are a lot of people interested in it, who may not have had any contact with [Prometheus](https://prometheus.io/). As a result if this there have been some misunderstandings in the past, how this all works. 20 | 21 | To avoid frustration you will need to know this: 22 | 23 | **You must setup and configure Prometheus separately!** If you are running in plain docker or docker-compose there is a docker-compose setup for Prometheus at which also includes Grafana to actually produce dashboards. This may work out of the box or can be used as a starting point. 24 | 25 | The whole setup required is: 26 | 27 | * fritz_exporter: connects to your Fritz device, reads the metrics and makes them available in a format Prometheus understands 28 | * prometheus: connects to the exporter at regular time intervals, reads the data and stores it in its database 29 | * grafana: connects to prometheus and can query the database of metrics for timeseries and create dashboards from it. 30 | 31 | **You cannot connect grafana to the exporter directly. This will not work**. 32 | 33 | Please check the "Quickstart" in the documentation at [ReadTheDocs](https://fritz-exporter.readthedocs.io) for a simple setup. 34 | 35 | ## Disclaimer 36 | 37 | Fritz! and AVM are registered trademarks of AVM GmbH. This project is not associated with AVM or Fritz other than using the devices and their names to refer to them. 38 | 39 | ## Copyright 40 | 41 | Copyright 2019-2025 Patrick Dreker 42 | 43 | Licensed under the Apache License, Version 2.0 (the "License"); 44 | you may not use this file except in compliance with the License. 45 | You may obtain a copy of the License at 46 | 47 | 48 | 49 | Unless required by applicable law or agreed to in writing, software 50 | distributed under the License is distributed on an "AS IS" BASIS, 51 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 52 | See the License for the specific language governing permissions and 53 | limitations under the License. 54 | -------------------------------------------------------------------------------- /docs/configuration.rst: -------------------------------------------------------------------------------- 1 | Configuration 2 | ============= 3 | 4 | The exporter supports two methods of configuration: 5 | 6 | * via environment variable 7 | * via config file 8 | 9 | .. _environment-config: 10 | 11 | Environment variable 12 | -------------------- 13 | 14 | If you only need a single device this is the easiest way to configure the exporter. 15 | 16 | +--------------------------+----------------------------------------------------+-----------+ 17 | | Env variable | Description | Default | 18 | +=========================-+====================================================+===========+ 19 | | ``FRITZ_NAME`` | User-friendly name for the device | Fritz!Box | 20 | +--------------------------+----------------------------------------------------+-----------+ 21 | | ``FRITZ_HOSTNAME`` | Hostname of the device | fritz.box | 22 | +--------------------------+----------------------------------------------------+-----------+ 23 | | ``FRITZ_USERNAME`` | Username to authenticate on the device | none | 24 | +--------------------------+----------------------------------------------------+-----------+ 25 | | ``FRITZ_PASSWORD`` | Password to use for authentication | none | 26 | +--------------------------+----------------------------------------------------+-----------+ 27 | | ``FRITZ_PASSWORD_FILE`` | File to read the password from | | 28 | +--------------------------+----------------------------------------------------+-----------+ 29 | | ``FRITZ_LISTEN_ADDRESS`` | Address to listen on. Can be IPv4 or IPv6. | 0.0.0.0 | 30 | +--------------------------+----------------------------------------------------+-----------+ 31 | | ``FRITZ_PORT`` | Listening port for the exporter | 9787 | 32 | +--------------------------+----------------------------------------------------+-----------+ 33 | | ``FRITZ_LOG_LEVEL`` | Application log level: ``DEBUG``, ``INFO``, | INFO | 34 | | | ``WARNING``, ``ERROR``, ``CRITICAL`` | | 35 | +--------------------------+----------------------------------------------------+-----------+ 36 | | ``FRITZ_HOST_INFO`` | Enable extended information about all WiFi | False | 37 | | | hosts. Only "true" or "1" will enable this feature | | 38 | +--------------------------+----------------------------------------------------+-----------+ 39 | 40 | .. note:: 41 | 42 | enabling ``FRITZ_HOST_INFO`` by setting it to ``true`` or ``1`` will collect extended information about every device known your fritz device which can take a long time (20+ seconds). If you really want or need the extended stats please make sure that your Prometheus scraping interval and timeouts are set accordingly. 43 | 44 | When using the environment vars you can only specify a single device. If you need multiple devices please use the config file. 45 | 46 | Example for a device (at 192.168.178.1 username "monitoring" and the password "mysupersecretpassword"): 47 | 48 | .. code-block:: bash 49 | 50 | export FRITZ_NAME='My Fritz!Box' 51 | export FRITZ_HOSTNAME='192.168.178.1' 52 | export FRITZ_USERNAME='monitoring' 53 | export FRITZ_PASSWORD='mysupersecretpassword' 54 | 55 | .. _config-file: 56 | 57 | Config file 58 | ----------- 59 | 60 | To use the config file you have to specify the the location of the config and mount the appropriate file into the container. The location can be specified by using the ``--config`` parameter. 61 | 62 | .. code-block:: yaml 63 | 64 | # Full example config file for Fritz-Exporter 65 | exporter_port: 9787 # optional 66 | log_level: DEBUG # optional 67 | devices: 68 | - name: Fritz!Box 7590 Router # optional 69 | hostname: fritz.box 70 | username: prometheus 71 | password: prometheus 72 | host_info: True 73 | - name: Repeater Wohnzimmer # optional 74 | hostname: repeater-Wohnzimmer 75 | username: prometheus 76 | password_file: /path/to/password.txt 77 | 78 | .. note:: 79 | 80 | enabling ``FRITZ_HOST_INFO`` by setting it to ``true`` or ``1`` will collect extended information about every device known your fritz device which can take a long time (20+ seconds). If you really want or need the extended stats pleade make sure, that your Prometheus scraping interval and timeouts are set accordingly. 81 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "fritz-exporter" 3 | version = "2.6.1" 4 | description = "Prometheus exporter for AVM Fritz! Devices" 5 | authors = ["Patrick Dreker "] 6 | license = "Apache 2.0" 7 | readme = "README.md" 8 | homepage = "https://github.com/pdreker/fritz_exporter" 9 | repository = "https://github.com/pdreker/fritz_exporter" 10 | documentation = "https://fritz-exporter.readthedocs.io" 11 | keywords = ["prometheus", "fritz", "router", "grafana"] 12 | classifiers = [ 13 | "Development Status :: 6 - Mature", 14 | "Environment :: Console", 15 | "License :: OSI Approved :: Apache Software License", 16 | "Operating System :: OS Independent", 17 | "Topic :: System :: Monitoring" 18 | ] 19 | include = [ "LICENSE.md" ] 20 | packages = [{include = "fritzexporter"}] 21 | 22 | [tool.poetry.dependencies] 23 | python = "^3.11" 24 | prometheus-client = ">=0.6.0" 25 | fritzconnection = ">=1.0.0" 26 | pyyaml = "*" 27 | requests = "*" 28 | attrs = ">=22.2,<26.0" 29 | defusedxml = "^0.7.1" 30 | 31 | [tool.poetry.group.dev.dependencies] 32 | pytest = "*" 33 | pytest-mock = "*" 34 | types-pyyaml = "*" 35 | types-requests = "*" 36 | mypy = ">=0.971,<1.20" 37 | coverage = ">=6.4.4,<8.0.0" 38 | pytest-cov = ">=3,<8" 39 | ruff = ">=0.1.7,<0.15.0" 40 | 41 | [tool.poetry.scripts] 42 | fritzexporter = "fritzexporter.__main__:main" 43 | 44 | [tool.pytest.ini_options] 45 | minversion = "6.0" 46 | addopts = "--cov-branch --cov . --cov-report xml --cov-config .coveragerc" 47 | testpaths = [ 48 | "tests", 49 | ] 50 | 51 | [build-system] 52 | requires = ["poetry-core"] 53 | build-backend = "poetry.core.masonry.api" 54 | 55 | [tool.ruff] 56 | line-length = 100 57 | target-version = "py311" 58 | extend-exclude = [ 59 | "tests", 60 | "docs", 61 | ] 62 | 63 | # other rules: 64 | # * "DJ" for Django 65 | # * "PYI" for type stubs etc. 66 | # * "PD" for PandasVet 67 | # * "NPY" for NumPy 68 | 69 | select = [ 70 | "E", "W", # PyCodeStyle 71 | "F", # PyFlakes 72 | "C90", # McCabe (Function Complexity 73 | "I", # ISort 74 | "N", # PEP8 Naming Conventions 75 | # "D", # PyDocStyle 76 | "UP", # pyupgrade 77 | "YTT", # flake8-2020 ('Yield from' etc.) 78 | "ANN", # flake8-annotations (missing type annotations) 79 | "ASYNC", # flake8-async (various async issues) 80 | "S", # flake8-bandit (security issues) 81 | "BLE", # blind exceptions 82 | "FBT", # boolean traps (Anti-Pattern, Google it.) 83 | "B", # bugbear (various anti-patterns) 84 | "A", # flake8-builtins (shadowing builtins) 85 | "COM", # flake8-commas (comma placement at line-end) 86 | "C4", # flake8-comprehensions (comprehension issues like unnecessary list comprehensions etc.) 87 | "DTZ", # avoid usage of naive datetime objects 88 | "T10", # watch for Debugger imports 89 | "EM", # ensure error messages are not formatted as f-strings and similar 90 | "FA", # flake8-future-annotations (ensure type hint annotation use mnodern syntax) 91 | "ISC", # implicit string concatenation 92 | "G", # flake8-logging-format (ensure logging format strings are valid) 93 | "INP", # do not use implicit namspace packages 94 | "PIE", # various anti-patterns and misfeatures 95 | "T20", # watch for print() calls 96 | "PT", # pytest style issues 97 | "Q", # quotes (ensure consistent usage of single/double quotes) 98 | "RSE", # some "raise" syntax issues 99 | "RET", # return values anti-patterns 100 | "SLF", # flake8-self (do not access "_private" attributes from outside) 101 | "SLOT", # flake8-slots (ensure usage of __slots__) 102 | "SIM", # flake8-simplify (simplify various constructs) 103 | "INT", # gettext issues (format strings etc.) 104 | "ARG", # disallow unused arguments 105 | "PTH", # use pathlib instead of os.path 106 | "TD", # enforce some syntax on TODO comments 107 | "FIX", # highlight TODO, FIXME, XXX etc. 108 | "PGH", # pygrep-hooks (policing "noqa" and similar) 109 | "PL", # PyLint (various issues) 110 | "TRY", # try/except/else/finally anti-patterns (try.ceratops) 111 | "FLY", # join vs. f-strings 112 | "PERF", # various performance issues 113 | "FURB", # modernize various constructs 114 | "LOG", # logging issues 115 | "RUF", # ruff (various issues) 116 | ] 117 | 118 | ignore = ['E203', 'COM812', 'ISC001', 'ANN101', 'ANN102', 'ANN204'] 119 | 120 | [tool.ruff.lint.pydocstyle] 121 | convention = "google" 122 | 123 | [tool.mypy] 124 | exclude = [ 125 | '^tests/', 126 | '^docs/', 127 | ] 128 | 129 | [[tool.mypy.overrides]] 130 | module = "fritzconnection.*" 131 | ignore_missing_imports = true 132 | 133 | [[tool.mypy.overrides]] 134 | module = "defusedxml.*" 135 | ignore_missing_imports = true -------------------------------------------------------------------------------- /docs/quickstart.rst: -------------------------------------------------------------------------------- 1 | .. _quickstart: 2 | 3 | Quickstart 4 | ========== 5 | 6 | This Quickstart is intended for users, who have no current Prometheus or Grafana instance running. This will use docker-compose to run the exporter, prometheus and Grafana. This will also require some configuration in Grafana to connect to prometheus. 7 | 8 | .. note:: 9 | 10 | Please understand that configuring Prometheus and Grafana are not part of this exporter and as such this is just intended to give a quick starting point. Problems with setting up and configuring either Prometheus and Grafana are out of scope for this quickstart. Please refrain from opening issues against this exporter for Prometheus or Grafana problems. 11 | 12 | Preparations 13 | ------------ 14 | 15 | Create a new user account for the exporter on the Fritzbox using the login credentials FRITZ_USERNAME: 'xxxxx' and FRITZ_PASSWORD: 'xxxx'. Grant the user access to the FRITZ!Box settings. This automaticaly also allows voice messages, fax messages, FRITZ!App Fon and call list, and Smart Home. 16 | 17 | Create an empty directory ``fritz-exporter`` and place a file ``docker-compose.yml`` in that directory with the following content (Check that environment variables and paths match your setup): 18 | 19 | .. code-block:: yaml 20 | 21 | version: "3.8" 22 | services: 23 | fritz-exporter: 24 | image: pdreker/fritz_exporter:2 25 | container_name: fritz-exporter 26 | restart: unless-stopped 27 | environment: 28 | FRITZ_HOSTNAME: '192.168.178.1' 29 | FRITZ_USERNAME: 'xxxxx' 30 | FRITZ_PASSWORD: 'xxxx' 31 | ports: 32 | - "9787:9787" 33 | 34 | prometheus: 35 | container_name: prometheus 36 | image: prom/prometheus:v2.38.0 37 | restart: unless-stopped 38 | volumes: 39 | - ./prometheus/:/etc/prometheus/ 40 | ports: 41 | - 9090:9090 42 | 43 | grafana: 44 | container_name: grafana 45 | image: grafana/grafana:9.1.6 46 | ports: 47 | - 3000:3000 48 | 49 | Create another empty directory ``prometheus`` next to the ``docker-compose.yml`` file and put a file ``prometheus.yml`` into it with the following content: 50 | 51 | .. code-block:: yaml 52 | 53 | global: 54 | scrape_interval: 15s 55 | evaluation_interval: 15s 56 | 57 | scrape_configs: 58 | - job_name: "fritz-exporter" 59 | scrape_interval: 60s 60 | static_configs: 61 | - targets: ["fritz-exporter:9787"] 62 | 63 | Next run ``docker compose up -d`` which should start the 3 services. Use ``docker ps -a`` to check, that all containers are actually running. 64 | 65 | .. code-block:: bash 66 | 67 | ❯ docker ps -a 68 | CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 69 | 2bb64e672ddf prom/prometheus:v2.38.0 "/bin/prometheus --c…" 3 seconds ago Up 2 seconds 0.0.0.0:9090->9090/tcp prometheus 70 | 3b4a78035327 pdreker/fritz_exporter:v2.1.1 "python -m fritzexpo…" 3 seconds ago Up 2 seconds 0.0.0.0:9787->9787/tcp fritz-exporter 71 | 9c7473367721 grafana/grafana:9.1.6 "/run.sh" 3 seconds ago Up 2 seconds 0.0.0.0:3000->3000/tcp grafana 72 | 73 | Checking Prometheus 74 | ------------------- 75 | 76 | Point your browser to http://localhost:9090/ to access the prometheus UI and navigate to Status -> Targets. You should see the fritz-exporter Target (the exporter) being **Up**. If the ``Last Scrape`` Column says "never", wait for a minute and reload. 77 | 78 | .. image:: _static/prometheus_target.png 79 | 80 | If your prometheus shows the exporter as a **Up** you should also be able to see some metrics via the autocompletion if you navigate back to the prometheus start page and enter ``fritz`` into the search bar. 81 | 82 | .. image:: _static/fritz_metrics.png 83 | 84 | Configuring Grafana 85 | ------------------- 86 | 87 | Now you can point your browser to http://localhost:3000/ to access Grafana. Login with username "admin" and password "admin" and set a new password (and make sure you do not forget that password). Once logged into Grafana go to "Configuration" (small Gear icon at the bottom end of the left sidebar) -> "Data Sources". 88 | 89 | .. image:: _static/grafana_datasrc.png 90 | 91 | Click "Add Data Source" and choose "Prometheus" from the list. Enter ``http://prometheus:9090`` for the URL, leave everything else as is, scroll down and click on "Save & Test". You should see a green checkmark indicating that Grafana was able to connect to prometheus. 92 | 93 | .. image:: _static/datasrc_ok.png 94 | 95 | Now go to "Dashboards" -> "Import" and enter "13983" into the "Import via grafana.com" input and click "Load". 96 | 97 | .. image:: _static/dashboard_import.png 98 | 99 | On the next page select your Prometheus datasource from the bottom dropdown and click "Import" and you should be greeted by a dashboard showing some data from your devices. 100 | 101 | .. image:: _static/dashboard.png 102 | -------------------------------------------------------------------------------- /fritzexporter/__main__.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import asyncio 3 | import logging 4 | import os 5 | import sys 6 | from pathlib import Path 7 | 8 | from prometheus_client import start_http_server 9 | from prometheus_client.core import REGISTRY 10 | 11 | from fritzexporter.config import ExporterError, get_config 12 | from fritzexporter.data_donation import donate_data 13 | from fritzexporter.fritzdevice import FritzCollector, FritzCredentials, FritzDevice 14 | 15 | from . import __version__ 16 | 17 | ch = logging.StreamHandler() 18 | formatter = logging.Formatter("%(asctime)s %(levelname)8s %(name)s | %(message)s") 19 | ch.setFormatter(formatter) 20 | 21 | logger = logging.getLogger("fritzexporter") 22 | logger.addHandler(ch) 23 | 24 | 25 | def parse_cmdline() -> argparse.Namespace: 26 | parser = argparse.ArgumentParser( 27 | description=f"Fritz Exporter for Prometheus using the TR-064 API (v{__version__})" 28 | ) 29 | parser.add_argument("--config", type=str, help="Path to config file") 30 | levels = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL") 31 | parser.add_argument( 32 | "--log-level", 33 | choices=levels, 34 | help="Set log-level (default: INFO)", 35 | ) 36 | 37 | parser.add_argument( 38 | "--donate-data", 39 | action="store_const", 40 | const="donate", 41 | help="Do not start exporter, collect and print data to assist the project", 42 | ) 43 | 44 | parser.add_argument( 45 | "--upload-data", 46 | action="store_const", 47 | const="upload", 48 | help="Instead of displaying the collected data donation, upload it.", 49 | ) 50 | 51 | parser.add_argument( 52 | "-s", 53 | "--sanitize", 54 | action="append", 55 | nargs="+", 56 | metavar="FIELD_SPEC", 57 | help="Sanitize 'service, action, field' from the data donation output", 58 | ) 59 | 60 | parser.add_argument( 61 | "--version", action="store_const", const="version", help="Print version number and exit." 62 | ) 63 | 64 | return parser.parse_args() 65 | 66 | 67 | def main() -> None: 68 | fritzcollector = FritzCollector() 69 | 70 | args = parse_cmdline() 71 | 72 | if args.version: 73 | print(__version__) # noqa: T201 74 | sys.exit(0) 75 | 76 | try: 77 | config = get_config(args.config) 78 | except ExporterError: 79 | logger.exception("Error while reading config:") 80 | sys.exit(1) 81 | 82 | log_level = ( 83 | getattr(logging, args.log_level) if args.log_level else getattr(logging, config.log_level) 84 | ) 85 | if not log_level: 86 | log_level = "INFO" 87 | 88 | loggers = [logging.getLogger(name) for name in logging.root.manager.loggerDict] 89 | for log in loggers: 90 | log.setLevel(log_level) 91 | 92 | for dev in config.devices: 93 | if dev.password_file is not None: 94 | # read password from password file, strip to get rid of newlines 95 | password = Path(dev.password_file).read_text().strip() 96 | logger.info("Using password from password file %s", dev.password_file) 97 | else: 98 | password = dev.password if dev.password is not None else "" 99 | fritz_device = FritzDevice( 100 | FritzCredentials(dev.hostname, dev.username, password), 101 | dev.name, 102 | host_info=dev.host_info, 103 | ) 104 | 105 | if args.donate_data == "donate": 106 | donate_data( 107 | fritz_device, 108 | upload=args.upload_data == "upload", 109 | sanitation=args.sanitize, 110 | ) 111 | sys.exit(0) 112 | else: 113 | logger.info("registering %s to collector", dev.hostname) 114 | fritzcollector.register(fritz_device) 115 | 116 | REGISTRY.register(fritzcollector) 117 | 118 | logger.info("Starting listener at %s:%d", config.listen_address, config.exporter_port) 119 | start_http_server(int(config.exporter_port), str(config.listen_address)) 120 | 121 | logger.info("Entering async main loop - exporter is ready") 122 | loop = asyncio.new_event_loop() 123 | 124 | # Avoid infinite loop if running tests 125 | if not os.getenv("FRITZ_EXPORTER_UNDER_TEST"): 126 | try: 127 | loop.run_forever() 128 | finally: 129 | loop.close() 130 | 131 | 132 | if __name__ == "__main__": 133 | main() 134 | 135 | # Copyright 2019-2025 Patrick Dreker 136 | # 137 | # Licensed under the Apache License, Version 2.0 (the "License"); 138 | # you may not use this file except in compliance with the License. 139 | # You may obtain a copy of the License at 140 | # 141 | # http://www.apache.org/licenses/LICENSE-2.0 142 | # 143 | # Unless required by applicable law or agreed to in writing, software 144 | # distributed under the License is distributed on an "AS IS" BASIS, 145 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 146 | # See the License for the specific language governing permissions and 147 | # limitations under the License. 148 | -------------------------------------------------------------------------------- /docs/helping_out.rst: -------------------------------------------------------------------------------- 1 | Helping out 2 | =========== 3 | 4 | If you think, that there are more metrics which can be read from your device or you just want to help out by providing a snapshot of the data, which can be read from your device or devices the exporter provides a function to gather information from your devices and send it to the author of this project. 5 | 6 | What it does 7 | ------------ 8 | 9 | The "data donation" function will collect the API functions which your device reports, the exporter capabilities detected for your device, the type of device and the current OS version. Additionally it will try reading all information, which the API provides. The information is sanitized before it is uploaded so there should not be any private data (e.g. usernames, passwords, IP addresses etc.) in the output. 10 | 11 | It then uploads that information to the project. The (quite trivial) server side of this can be seen at `this GitHub Repo `_. 12 | 13 | You can verify the output before sending it to me, by printing it to the screen and check for private data and telling the exporter to sanitize any additional bits I may have missed in the code. 14 | 15 | What it does not do 16 | ------------------- 17 | 18 | This will try the hardest not to collect any private information from your device. While I cannot guarantee, that it will never contain any private data in the output, as this function blindly scans all "Get" endpoints your device offers, I have taken steps to ensure that it should already sanitize the most obvious "leaks". 19 | 20 | What the data will be used for 21 | ------------------------------ 22 | 23 | The data collected will be used for testing the code against a wider variety of devices and can be used to identify more metrics which I may be unable to reverse engineer, simply because I do not have that device or type of internet connection. 24 | 25 | Actually donating data 26 | ---------------------- 27 | 28 | The data donation can be done using the normal docker image, which is also used to run the exporter. Simply add the ``--donate-data`` option to the command line to show what data would be collected. 29 | 30 | Assuming you have the config file in your current directory, simply run 31 | 32 | .. code-block:: bash 33 | 34 | docker run -v $(pwd)/fritz-exporter.yml:/app/fritz-exporter.yml --rm pdreker/fritz_exporter:latest --config fritz-exporter.yml --donate-data 35 | 36 | Or if you use environment variables for the configuration: 37 | 38 | .. code-block:: bash 39 | 40 | docker run -e FRITZ_HOSTNAME="fritz.box" -e FRITZ_USERNAME="myusername" -e FRITZ_PASSWORD="mypassword" --rm pdreker/fritz_exporter:latest --donate-data 41 | 42 | After you check the data printed to screen you can simply add the ``--upload-data`` parameter to the end of the command line and instead of printing out the data to screen it will be uploaded: 43 | 44 | .. code-block:: bash 45 | 46 | docker run -e FRITZ_HOSTNAME="fritz.box" -e FRITZ_USERNAME="myusername" -e FRITZ_PASSWORD="mypassword" --rm pdreker/fritz_exporter:latest --donate-data --upload-data 47 | 48 | After the data is uploaded successfully you will see a log message with an ID. If you want to open an issue you can use the ID to reference your data, so I can find it. 49 | 50 | Help! There is actually private data in my output! 51 | -------------------------------------------------- 52 | 53 | If there is any data you do not want to share in the output there is an option to sanitize the output further. By adding the ``-s SERVICE ACTION FIELD`` option you can tell the donation to sanitize the corresponding field. If you omit the ``FIELD`` all fields for that action will be sanitized. Please use this sparingly, as the more data is present, the more useful the data is. 54 | 55 | For example you have the following output snippet 56 | 57 | .. code-block:: text 58 | 59 | ... 60 | } 61 | }, 62 | "WANIPConnection1": { 63 | "GetInfo": { 64 | "NewEnable": "True", 65 | "NewConnectionStatus": "Connecting", 66 | "NewPossibleConnectionTypes": "IP_Routed, IP_Bridged", 67 | "NewConnectionType": "IP_Routed", 68 | "NewName": "mstv", 69 | "NewUptime": "0", 70 | ... 71 | 72 | and you want the ``NewName`` field to be sanitized, you can add ``-s WANIPConnection1 GetInfo NewName`` to your command line and your output will now look like this: 73 | 74 | .. code-block:: text 75 | 76 | ... 77 | } 78 | }, 79 | "WANIPConnection1": { 80 | "GetInfo": { 81 | "NewEnable": "True", 82 | "NewConnectionStatus": "Connecting", 83 | "NewPossibleConnectionTypes": "IP_Routed, IP_Bridged", 84 | "NewConnectionType": "IP_Routed", 85 | "NewName": , 86 | "NewUptime": "0", 87 | ... 88 | 89 | If you just specified ``-s WANIPConnection1 GetInfo`` all fields in the ``GetInfo`` block would be sanitized. The ``-s`` (or ``--sanitized``) option can be repeated multiple times, as needed: 90 | 91 | .. code-block:: bash 92 | 93 | docker run -e FRITZ_HOSTNAME="fritz.box" -e FRITZ_USERNAME="myusername" -e FRITZ_PASSWORD="mypassword" --rm pdreker/fritz_exporter:latest --donate-data -s WANIPConnection1 GetInfo NewName -s WANPPPConnection1 GetInfo NewRSIPAvailable 94 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from fritzexporter.config import ( 4 | ConfigError, 5 | ConfigFileUnreadableError, 6 | DeviceConfig, 7 | EmptyConfigError, 8 | ExporterConfig, 9 | NoDevicesFoundError, 10 | get_config, 11 | ) 12 | 13 | 14 | class TestReadConfig: 15 | def test_file_not_found(self): 16 | testfile = "this/does/not/exist" 17 | 18 | with pytest.raises(ConfigFileUnreadableError): 19 | get_config(testfile) 20 | 21 | def test_no_config(self, monkeypatch): 22 | testfile = None 23 | 24 | monkeypatch.delenv("FRITZ_HOSTNAME", raising=False) 25 | monkeypatch.delenv("FRITZ_USERNAME", raising=False) 26 | monkeypatch.delenv("FRITZ_PASSWORD", raising=False) 27 | monkeypatch.delenv("FRITZ_NAME", raising=False) 28 | with pytest.raises(ConfigError): 29 | get_config(testfile) 30 | 31 | 32 | class TestFileConfigs: 33 | def test_empty_file(self): 34 | testfile = "tests/conffiles/empty.yaml" 35 | 36 | with pytest.raises(EmptyConfigError): 37 | _ = get_config(testfile) 38 | 39 | def test_empty_devices(self): 40 | testfile = "tests/conffiles/emptydevices.yaml" 41 | 42 | with pytest.raises(NoDevicesFoundError): 43 | _ = get_config(testfile) 44 | 45 | def test_malformed_device(self): 46 | testfile = "tests/conffiles/malformeddevice.yaml" 47 | 48 | with pytest.raises(ValueError): 49 | _ = get_config(testfile) 50 | 51 | def test_nodevices(self): 52 | testfile = "tests/conffiles/nodevices.yaml" 53 | 54 | with pytest.raises(NoDevicesFoundError): 55 | _ = get_config(testfile) 56 | 57 | def test_invalidport(self): 58 | testfile = "tests/conffiles/invalidport.yaml" 59 | 60 | with pytest.raises(ValueError): 61 | _ = get_config(testfile) 62 | 63 | def test_valid_file(self): 64 | testfile = "tests/conffiles/validconfig.yaml" 65 | 66 | expected = ExporterConfig( 67 | listen_address="127.0.0.1", 68 | devices=[ 69 | DeviceConfig( 70 | "fritz.box", 71 | "prometheus1", 72 | "prometheus2", 73 | None, 74 | "Fritz!Box 7590 Router", 75 | False, 76 | ), 77 | DeviceConfig( 78 | "repeater-Wohnzimmer", 79 | "prometheus3", 80 | "prometheus4", 81 | None, 82 | "Repeater Wohnzimmer", 83 | False, 84 | ), 85 | ] 86 | ) 87 | 88 | config = get_config(testfile) 89 | assert config == expected 90 | 91 | def test_password_file(self): 92 | testfile = "tests/conffiles/password_file.yaml" 93 | 94 | expected = ExporterConfig( 95 | devices=[ 96 | DeviceConfig( 97 | "fritz.box", 98 | "prometheus1", 99 | None, 100 | "tests/conffiles/password.txt", 101 | "Fritz!Box 7590 Router", 102 | False, 103 | ), 104 | ] 105 | ) 106 | config = get_config(testfile) 107 | assert config == expected 108 | 109 | class TestEnvConfig: 110 | def test_env_config(self, monkeypatch): 111 | monkeypatch.setenv("FRITZ_HOSTNAME", "hostname.local") 112 | monkeypatch.setenv("FRITZ_USERNAME", "SomeUserName") 113 | monkeypatch.setenv("FRITZ_PASSWORD", "AnInterestingPassword") 114 | monkeypatch.setenv("FRITZ_NAME", "My Fritz Device") 115 | monkeypatch.setenv("FRITZ_LISTEN_ADDRESS", "127.0.0.2") 116 | monkeypatch.setenv("FRITZ_PORT", "12345") 117 | monkeypatch.setenv("FRITZ_LOG_LEVEL", "INFO") 118 | 119 | config = get_config(None) 120 | devices: list[DeviceConfig] = [ 121 | DeviceConfig( 122 | "hostname.local", 123 | "SomeUserName", 124 | "AnInterestingPassword", 125 | None, 126 | "My Fritz Device", 127 | ) 128 | ] 129 | expected: ExporterConfig = ExporterConfig(12345, "INFO", devices, "127.0.0.2") 130 | 131 | assert config == expected 132 | 133 | def test_minimal_env_config(self, monkeypatch): 134 | monkeypatch.setenv("FRITZ_USERNAME", "SomeUserName") 135 | monkeypatch.setenv("FRITZ_PASSWORD", "AnInterestingPassword") 136 | 137 | config = get_config(None) 138 | devices: list[DeviceConfig] = [ 139 | DeviceConfig( 140 | "fritz.box", "SomeUserName", "AnInterestingPassword", None, "Fritz!Box" 141 | ) 142 | ] 143 | expected: ExporterConfig = ExporterConfig(9787, "INFO", devices) 144 | 145 | assert config == expected 146 | 147 | def test_password_file_env_config(self, monkeypatch): 148 | monkeypatch.setenv("FRITZ_USERNAME", "SomeUserName") 149 | monkeypatch.setenv("FRITZ_PASSWORD_FILE", "tests/conffiles/password.txt") 150 | 151 | config = get_config(None) 152 | devices: list[DeviceConfig] = [ 153 | DeviceConfig( 154 | "fritz.box", "SomeUserName", None, "tests/conffiles/password.txt", "Fritz!Box" 155 | ) 156 | ] 157 | expected: ExporterConfig = ExporterConfig(9787, "INFO", devices) 158 | 159 | assert config == expected 160 | -------------------------------------------------------------------------------- /docs/running.rst: -------------------------------------------------------------------------------- 1 | Running 2 | ======= 3 | 4 | plain Docker (docker run) 5 | ------------------------- 6 | 7 | Docker images are automatically pushed to Docker Hub and are built for linux/amd64, linux/arm/v6, linux/arm/v7 and linux/arm64, the latter three being useful for e.g. Raspberry Pi type systems. 8 | 9 | To run simply use 10 | 11 | .. code-block:: bash 12 | 13 | docker run -d -e FRITZ_USERNAME="prometheus" -e FRITZ_PASSWORD="monitoring" -p 9787:9787 --name fritz_exporter pdreker/fritz_exporter 14 | 15 | 16 | This will use the default hostname of ``fritz.box`` for the device and use the default name of `Fritz!Box` 17 | 18 | If you are monitoring multiple device you must use the config file method like this: 19 | 20 | .. code-block:: bash 21 | 22 | docker run -d -v /path/to/fritz-exporter.yaml:/app/fritz-exporter.yaml -p 9787:9787 --name fritz_exporter pdreker/fritz_exporter --config /app/fritz-exporter.yaml 23 | 24 | ``/path/to/fritz-exporter.yaml`` is your local copy of the configuration, which will be mounted into the container. 25 | 26 | See the example config file provided at :ref:`config-file`. 27 | 28 | docker-compose 29 | -------------- 30 | 31 | Config via environment variables 32 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 33 | 34 | To run the exporter from docker-compose create an empty directory ``fritz-exporter`` and put a file ``docker-compose.yml`` in the directory (replace the environment variables with appropriate values) with the following content: 35 | 36 | .. code-block:: yaml 37 | 38 | # Example file for running tzhe exporter from the published image at hub.docker.com 39 | version: "3.8" 40 | services: 41 | fritz-exporter: 42 | image: pdreker/fritz_exporter:2 43 | container_name: fritz-exporter 44 | restart: always 45 | environment: 46 | FRITZ_HOSTNAME: 'fritz.box' 47 | FRITZ_USERNAME: 'prometheus' 48 | FRITZ_PASSWORD: 'prompassword' 49 | ports: 50 | - "9787:9787" 51 | 52 | Then run ``docker compose up -d`` to start the exporter. 53 | 54 | Config via config file 55 | ^^^^^^^^^^^^^^^^^^^^^^ 56 | 57 | Create an empty directory ``fritz-exporter`` and put a file ``docker-compose.yml`` in the directory (check the correct path for the config file) with the following content: 58 | 59 | .. code-block:: yaml 60 | 61 | version: "3.8" 62 | services: 63 | fritz-exporter: 64 | image: pdreker/fritz_exporter:2 65 | command: --config /fritz-exporter.yml 66 | build: ../ 67 | container_name: fritz-exporter 68 | restart: always 69 | ports: 70 | - "9787:9787" 71 | volumes: 72 | - "/path/to/fritz-exporter.yml:/fritz-exporter.yml" 73 | 74 | Create a config file for the exporter in this directory named ``fritz-exporter.yml``. See the example config file provided at :ref:`config-file`. 75 | 76 | Bare Metal (no container) 77 | ------------------------- 78 | 79 | Running directly from sources is not recommended and should only be used for development. Please run this from docker/containers for real use. 80 | 81 | This exporter requires Python >=3.10. 82 | 83 | This project uses poetry (as of v2.1.2) to manage dependecies. As such you can simply recreate the neccessary virtual environment for this exporter by running ``poetry install`` from the checked out repository. 84 | 85 | The exporter can directly be run from a shell. Set the environment vars or config file as described in the configuration section of this README and run ``python3 -m fritzbox_exporter [--config /path/to/config/file.yaml]`` from the code directory. 86 | 87 | Systemd 88 | ------- 89 | 90 | It's also possible to run the exporter using a `systemd `_ service. 91 | 92 | Make sure you have python >=3.10 and pip installed on your system. 93 | Usually these packages are called ``python3`` and ``python3-pip``. Please consult your distro documentation. 94 | 95 | Create a new user for the exporter. 96 | 97 | .. code-block:: shell 98 | 99 | useradd --home-dir /opt/fritz-exporter \ 100 | --create-home \ 101 | --system \ 102 | --shell /usr/sbin/nologin \ 103 | fritz-exporter 104 | 105 | Note: If you get a warning similar to ``useradd: Warning: missing or non-executable shell '/usr/sbin/nologin'``, thats completely normal! 106 | 107 | Install fritzexporter using pip for the new user. 108 | 109 | .. code-block:: shell 110 | 111 | sudo --user=fritz-exporter \ 112 | pip install --user \ 113 | fritz-exporter \ 114 | --no-warn-script-location 115 | 116 | Now create the systemd service file at ``/etc/systemd/system/fritz-exporter.service`` with the following content: 117 | 118 | .. literalinclude:: fritz-exporter.service 119 | :language: ini 120 | 121 | Create your configuration file at ``/etc/fritz-exporter/config.yaml``. 122 | See the example config file provided at :ref:`config-file`. 123 | 124 | If you changed something in the fritz-exporter.service file, make sure to run ``systemctl daemon-reload`` afterwards, otherwise your changes won't get picked up. 125 | 126 | .. code-block:: shell 127 | 128 | # create configuration directory 129 | mkdir -p /etc/fritz-exporter 130 | 131 | # create configuration file using your favorite editor e.g. vim, nano 132 | nano /etc/fritz-exporter/config.yaml 133 | 134 | # Change owner and group of config to fritz-exporter 135 | chown fritz-exporter:fritz-exporter /etc/fritz-exporter/config.yaml 136 | # Change permissions to only allow the owner to read and write this file 137 | chmod 600 /etc/fritz-exporter/config.yaml 138 | 139 | Start and enable the service to start at boot. 140 | 141 | .. code-block:: shell 142 | 143 | systemctl start fritz-exporter.service 144 | systemctl enable fritz-exporter.service 145 | 146 | # Check the service status 147 | systemctl status fritz-exporter.service 148 | 149 | # View service logs 150 | journalctl -fe -u fritz-exporter.service 151 | -------------------------------------------------------------------------------- /fritzexporter/fritzdevice.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import logging 3 | import sys 4 | import threading 5 | from typing import NamedTuple 6 | 7 | from fritzconnection import FritzConnection # type: ignore[import] 8 | from fritzconnection.core.exceptions import ( # type: ignore[import] 9 | FritzActionError, 10 | FritzAuthorizationError, 11 | FritzConnectionException, 12 | FritzServiceError, 13 | ) 14 | from prometheus_client.core import CounterMetricFamily, GaugeMetricFamily 15 | from prometheus_client.registry import Collector 16 | 17 | from fritzexporter.exceptions import FritzDeviceHasNoCapabilitiesError 18 | from fritzexporter.fritzcapabilities import FritzCapabilities 19 | 20 | logger = logging.getLogger("fritzexporter.fritzdevice") 21 | 22 | 23 | FRITZ_MAX_PASSWORD_LENGTH = 32 24 | 25 | 26 | class FritzCredentials(NamedTuple): 27 | host: str 28 | user: str 29 | password: str 30 | 31 | 32 | class FritzDevice: 33 | def __init__(self, creds: FritzCredentials, name: str, *, host_info: bool = False) -> None: 34 | self.host: str = creds.host 35 | self.serial: str = "n/a" 36 | self.model: str = "n/a" 37 | self.friendly_name: str = name 38 | self.host_info: bool = host_info 39 | 40 | if len(creds.password) > FRITZ_MAX_PASSWORD_LENGTH: 41 | logger.warning( 42 | "Password is longer than %d characters! Login may not succeed, please see README!", 43 | FRITZ_MAX_PASSWORD_LENGTH, 44 | ) 45 | 46 | try: 47 | self.fc: FritzConnection = FritzConnection( 48 | address=creds.host, user=creds.user, password=creds.password 49 | ) 50 | except FritzConnectionException: 51 | logger.exception("unable to connect to %s.", creds.host) 52 | raise 53 | 54 | self.get_device_info() 55 | 56 | logger.info("Connection to %s successful, reading capabilities", creds.host) 57 | self.capabilities = FritzCapabilities(self) 58 | 59 | logger.info( 60 | "Reading capabilities for %s, got serial %s, model name %s completed", 61 | creds.host, 62 | self.serial, 63 | self.model, 64 | ) 65 | if host_info: 66 | logger.info( 67 | "HostInfo Capability enabled on device %s. " 68 | "This will cause slow responses from the exporter. " 69 | "Ensure prometheus is configured appropriately.", 70 | creds.host, 71 | ) 72 | if self.capabilities.empty(): 73 | logger.critical("Device %s has no detected capabilities. Exiting.", creds.host) 74 | raise FritzDeviceHasNoCapabilitiesError 75 | 76 | def get_device_info(self) -> None: 77 | try: 78 | device_info: dict[str, str] = self.fc.call_action("DeviceInfo1", "GetInfo") 79 | self.serial = device_info["NewSerialNumber"] 80 | self.model = device_info["NewModelName"] 81 | 82 | except (FritzServiceError, FritzActionError): 83 | logger.exception( 84 | "Fritz Device %s does not provide basic device " 85 | "info (Service: DeviceInfo1, Action: GetInfo)." 86 | "Serial number and model name will be unavailable.", 87 | self.host, 88 | ) 89 | except FritzAuthorizationError: 90 | logger.exception( 91 | "Not authorized to get device info from %s. Check username/password.", self.host 92 | ) 93 | raise 94 | 95 | def get_connection_mode(self) -> GaugeMetricFamily | None: 96 | """ 97 | Returns a metric to detect whether device is in DSL, mobile fallback or offline mode. 98 | """ 99 | try: 100 | resp = self.fc.call_action("WANCommonInterfaceConfig", "GetCommonLinkProperties") 101 | link_status = resp.get("NewPhysicalLinkStatus") 102 | access_type = resp.get("NewWANAccessType") 103 | except FritzConnectionException: 104 | logger.warning("Failed to retrieve connection mode info from %s", self.host) 105 | return None 106 | 107 | if link_status == "Up" and access_type == "DSL": 108 | mode = 1 # DSL connection active 109 | elif link_status == "Down" and access_type == "X_AVM-DE_Mobile": 110 | mode = 2 # DSL disconnected -> Fallback mobile conenction active 111 | elif link_status == "Up" and access_type == "X_AVM-DE_Mobile": 112 | mode = 3 # DSL disabled, only mobile connection active 113 | else: 114 | mode = 0 # Disconnected or not available 115 | 116 | m = GaugeMetricFamily( 117 | "fritz_connection_mode", 118 | "Connection mode: 1=DSL, 2=Mobile fallback, 3=Mobile-only, 0=offline/unknown", 119 | labels=["access_type", "friendly_name"], 120 | ) 121 | m.add_metric([access_type, self.friendly_name], mode) 122 | return m 123 | 124 | 125 | class FritzCollector(Collector): 126 | def __init__(self) -> None: 127 | self.devices: list[FritzDevice] = [] 128 | self.capabilities: FritzCapabilities = FritzCapabilities() # host_info=True??? FIXME 129 | self._collect_lock = threading.RLock() 130 | 131 | def register(self, fritzdev: FritzDevice) -> None: 132 | self.devices.append(fritzdev) 133 | logger.debug("registered device %s (%s) to collector", fritzdev.host, fritzdev.model) 134 | self.capabilities.merge(fritzdev.capabilities) 135 | 136 | def collect(self) -> collections.abc.Iterable[CounterMetricFamily | GaugeMetricFamily]: 137 | with self._collect_lock: 138 | if not self.devices: 139 | logger.critical("No devices registered in collector! Exiting.") 140 | sys.exit(1) 141 | 142 | # Custom mode metric 143 | for dev in self.devices: 144 | mode_metric = dev.get_connection_mode() 145 | if mode_metric: 146 | yield mode_metric 147 | 148 | for name, capa in self.capabilities.items(): 149 | capa.create_metrics() 150 | yield from capa.get_metrics(self.devices, name) 151 | 152 | 153 | # Copyright 2019-2025 Patrick Dreker 154 | # 155 | # Licensed under the Apache License, Version 2.0 (the "License"); 156 | # you may not use this file except in compliance with the License. 157 | # You may obtain a copy of the License at 158 | # 159 | # http://www.apache.org/licenses/LICENSE-2.0 160 | # 161 | # Unless required by applicable law or agreed to in writing, software 162 | # distributed under the License is distributed on an "AS IS" BASIS, 163 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 164 | # See the License for the specific language governing permissions and 165 | # limitations under the License. 166 | -------------------------------------------------------------------------------- /fritzexporter/config/config.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import ipaddress 4 | import logging 5 | import os 6 | from pathlib import Path 7 | from typing import Any 8 | 9 | import attrs 10 | import yaml 11 | from attrs import converters, define, field, validators 12 | 13 | from fritzexporter.fritzdevice import FRITZ_MAX_PASSWORD_LENGTH 14 | 15 | from .exceptions import ( 16 | ConfigError, 17 | ConfigFileUnreadableError, 18 | EmptyConfigError, 19 | FritzPasswordFileDoesNotExistError, 20 | FritzPasswordTooLongError, 21 | NoDevicesFoundError, 22 | ) 23 | 24 | logger = logging.getLogger("fritzexporter.config") 25 | 26 | 27 | def _read_config_file(config_file_path: str) -> dict: 28 | try: 29 | with Path(config_file_path).open() as config_file: 30 | config = yaml.safe_load(config_file) 31 | 32 | except OSError as e: 33 | logger.exception("Config file specified but could not be read.") 34 | raise ConfigFileUnreadableError from e 35 | logger.info("Read configuration from %s.", config_file_path) 36 | 37 | return config 38 | 39 | 40 | def _read_config_from_env() -> dict: 41 | if "FRITZ_USERNAME" not in os.environ or all( 42 | required not in os.environ for required in ["FRITZ_PASSWORD", "FRITZ_PASSWORD_FILE"] 43 | ): 44 | logger.critical( 45 | "Required env variables missing " 46 | "(FRITZ_USERNAME, FRITZ_PASSWORD or FRITZ_PASSWORD_FILE)!" 47 | ) 48 | msg = ( 49 | "Required env variables missing " 50 | "(FRITZ_USERNAME, FRITZ_PASSWORD or FRITZ_PASSWORD_FILE)!" 51 | ) 52 | raise ConfigError(msg) 53 | 54 | listen_address = os.getenv("FRITZ_LISTEN_ADDRESS") 55 | exporter_port = os.getenv("FRITZ_PORT") 56 | log_level = os.getenv("FRITZ_LOG_LEVEL") 57 | 58 | hostname = os.getenv("FRITZ_HOSTNAME") 59 | name: str = os.getenv("FRITZ_NAME", "Fritz!Box") 60 | username = os.getenv("FRITZ_USERNAME") 61 | password = os.getenv("FRITZ_PASSWORD") 62 | password_file = os.getenv("FRITZ_PASSWORD_FILE") 63 | 64 | host_info: str = os.getenv("FRITZ_HOST_INFO", "False") 65 | 66 | config: dict[Any, Any] = {} 67 | if exporter_port: 68 | config["exporter_port"] = exporter_port 69 | if log_level: 70 | config["log_level"] = log_level 71 | if listen_address: 72 | config["listen_address"] = listen_address 73 | 74 | config["devices"] = [] 75 | device = { 76 | "username": username, 77 | "password": password, 78 | "password_file": password_file, 79 | "host_info": host_info, 80 | "name": name, 81 | } 82 | if hostname: 83 | device["hostname"] = hostname 84 | config["devices"].append(device) 85 | 86 | logger.info("No configuration file specified: configuration read from environment") 87 | 88 | return config 89 | 90 | 91 | def get_config(config_file_path: str | None) -> ExporterConfig: 92 | config = _read_config_file(config_file_path) if config_file_path else _read_config_from_env() 93 | return ExporterConfig.from_config(config) 94 | 95 | 96 | @define 97 | class ExporterConfig: 98 | exporter_port: int = field( 99 | default=9787, 100 | validator=[ 101 | validators.instance_of(int), 102 | validators.ge(1024), 103 | validators.le(65535), 104 | ], 105 | converter=int, 106 | ) 107 | log_level: str = field( 108 | default="INFO", validator=validators.in_(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]) 109 | ) 110 | devices: list[DeviceConfig] = field(factory=list) 111 | # TODO(pdreker): Don't bind to 0.0.0.0 by default 112 | # https://github.com/pdreker/fritz_exporter/issues/402 113 | listen_address: str = field(default="0.0.0.0") # noqa: S104 114 | 115 | @devices.validator 116 | def check_devices(self, _: attrs.Attribute, value: list[DeviceConfig]) -> None: 117 | if value in [None, []]: 118 | logger.exception("No devices found in config.") 119 | msg = "No devices found in config." 120 | raise NoDevicesFoundError(msg) 121 | devicenames = [dev.name for dev in value] 122 | if len(devicenames) != len(set(devicenames)): 123 | logger.warning("Device names are not unique") 124 | 125 | @listen_address.validator 126 | def check_listen_address(self, _: attrs.Attribute, value: str) -> None: 127 | _: ipaddress.IPv4Address | ipaddress.IPv6Address = ipaddress.ip_address(value) 128 | 129 | @classmethod 130 | def from_config(cls, config: dict) -> ExporterConfig: 131 | if config is None: 132 | logger.exception("No config found (check Env vars or config file).") 133 | msg = "No config found (check Env vars or config file)." 134 | raise EmptyConfigError(msg) 135 | 136 | exporter_port = config.get("exporter_port", 9787) 137 | log_level = config.get("log_level", "INFO") 138 | devices: list[DeviceConfig] = [ 139 | DeviceConfig.from_config(dev) for dev in config.get("devices", []) 140 | ] 141 | # TODO(pdreker): Don't bind to 0.0.0.0 by default 142 | # https://github.com/pdreker/fritz_exporter/issues/402 143 | listen_address = config.get("listen_address", "0.0.0.0") # noqa: S104 144 | 145 | if listen_address in ["0.0.0.0", "::"]: # noqa: S104 146 | logger.warning("Binding to all interfaces with listen_address=%s", listen_address) 147 | logger.warning("Future versions of FritzExporter will switch to a more secure default.") 148 | logger.warning( 149 | "Please consider setting FRITZ_LISTEN_ADDRESS or using listen_address in config" 150 | "file to a specific address." 151 | ) 152 | return cls( 153 | exporter_port=exporter_port, 154 | log_level=log_level, 155 | devices=devices, 156 | listen_address=listen_address, 157 | ) 158 | 159 | 160 | @define 161 | class DeviceConfig: 162 | hostname: str = field(validator=validators.min_len(1), converter=lambda x: str.lower(x)) 163 | username: str = field(validator=validators.min_len(1)) 164 | password: str | None = field(default=None) 165 | password_file: str | None = field(default=None) 166 | name: str = "" 167 | host_info: bool = field(default=False, converter=converters.to_bool) 168 | 169 | @password.validator 170 | def check_password(self, _: attrs.Attribute, value: str | None) -> None: 171 | if value is not None and len(value) > FRITZ_MAX_PASSWORD_LENGTH: 172 | logger.exception( 173 | "Password is longer than 32 characters! " 174 | "Login may not succeed, please see documentation!" 175 | ) 176 | raise FritzPasswordTooLongError 177 | 178 | @password_file.validator 179 | def check_password_file(self, _: attrs.Attribute, value: str | None) -> None: 180 | if value is not None and not Path(value).is_file(): 181 | logger.exception("Password file does not exist!") 182 | raise FritzPasswordFileDoesNotExistError 183 | 184 | @classmethod 185 | def from_config(cls, device: dict) -> DeviceConfig: 186 | hostname = device.get("hostname", "fritz.box") 187 | username = device.get("username", "") 188 | password = device.get("password") 189 | password_file = device.get("password_file") 190 | name = device.get("name", "") 191 | host_info = device.get("host_info", False) 192 | 193 | return cls( 194 | hostname=hostname, 195 | username=username, 196 | password=password, 197 | password_file=password_file, 198 | name=name, 199 | host_info=host_info, 200 | ) 201 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. fritz-exporter documentation master file, created by 2 | sphinx-quickstart on Sat Sep 24 13:59:17 2022. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to fritz-exporter's documentation! 7 | ========================================== 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | :caption: Contents: 12 | 13 | quickstart 14 | upgrading 15 | configuration 16 | running 17 | docker-images 18 | helping_out 19 | building 20 | coding 21 | 22 | Fritz! exporter for prometheus 23 | ============================== 24 | 25 | This is a prometheus exporter for AVM Fritz! home network devices commonly found in Europe. This exporter uses the devices builtin TR-064 API via the fritzconnection python module. 26 | 27 | The exporter should work with Fritz!Box and Fritz!Repeater Devices (and maybe others). It actively checks for supported metrics and queries the metrics for all devices configured (Yes, it has multi-device support for all you Mesh users out there.) 28 | 29 | It has been tested against an AVM Fritz!Box 7590 (DSL), a Fritz!Repeater 2400 and a Fritz!WLAN Repeater 1750E. If you have another box and data is missing, please file an issue or PR on GitHub. 30 | 31 | 32 | .. note:: 33 | **Prometheus required** 34 | 35 | As the scope of this exporter lies on a typical home device, this also means that there are a lot of people interested in it, who may not have had any contact with `Prometheus `_. As a result of this there have been some misunderstandings in the past, how this all works. 36 | 37 | To avoid frustration you will need to know this: 38 | 39 | **You must setup and configure Prometheus separately!** If you are running in plain docker or docker-compose there is a docker-compose setup for Prometheus at https://github.com/vegasbrianc/prometheus which also includes Grafana to actually produce dashboards. This may work out of the box or can be used as a starting point. 40 | 41 | The whole setup required is: 42 | 43 | * fritz_exporter: connects to your Fritz device, reads the metrics and makes them available in a format Prometheus understands 44 | * prometheus: connects to the exporter at regular time intervals, reads the data and stores it in its database 45 | * grafana: connects to prometheus and can query the database of metrics for timeseries and create dashboards from it. 46 | 47 | Check out the :ref:`quickstart`, which will bring up a simple and limited Prometheus, Grafana and exporter setup. 48 | 49 | **You cannot connect grafana to the exporter directly. This will not work**. 50 | 51 | Metrics 52 | ------- 53 | 54 | The following groups of metrics are currently available: 55 | 56 | * Base Information (Model, Serial, Software Version, Uptime) 57 | * Software Information (Update available) 58 | * LAN statistics (Ethernet only) 59 | * WAN statistics 60 | * DSL statistics 61 | * PPP statistics 62 | * WiFi statistics 63 | * WAN Layer1 (physical link) statistics 64 | * Home Automation Devices (The TR-064 API is currently limited to switches, heating valves, temperatures and power meters - maybe AVM updates this in the future. Especially windows sensors (open/closed) and the battery status of the devices are currently not reported due to these values simply not being reported by the device.) 65 | 66 | If there is any information missing or not displayed on your specific device, please open an issue on GitHub. 67 | 68 | Known Problems 69 | -------------- 70 | 71 | * It seems like Fritz!OS does not internally count the packets for the Guest WiFi. So even though those counters are there they are always 0. This seems to be a problem with Fritz!OS and not the exporter. The counters are delivered nontheless, just in case this gets fixed by AVM. 72 | * If you receive ``Fatal Python error: init_interp_main: can't initialize time`` when running the container you may have to update libseccomp on your Docker host. This issue mainly happens on Raspberry Pi and is triggered by a version of libseccomp2 which is too old. See https://askubuntu.com/questions/1263284/apt-update-throws-signature-error-in-ubuntu-20-04-container-on-arm (Method 2) and https://github.com/pdreker/fritz_exporter/issues/38. 73 | * On some boxes LAN Packet counters are stuck at 0 even though the box reports the stats as available. 74 | * Fritz!OS does not allow passwords longer than 32 characters (as of 07.25). If you try to use a longer password, the admin ui will simply discard all characters after the 32nd. The UI will also cut your inserted password down to 32 characters. So you will be able to login in the UI with the long password. The exporter however does not alter your password and requests will result in a ``401 Unauthorized`` error. So please be aware of this limit and choose a suitable password. 75 | * Collecting HostInfo (disabled by default) can be extremely slow and will cause some load on the device. It works, but it is slow as this feature needs two calls to the Fritz! device for every device it knows which will simply take some time. If you enable this, make sure your Prometheusm `scrape_timeouts` are set appropriately (30s should be OK for most setups, but you may need to go even higher). 76 | 77 | Grafana Dashboards 78 | ------------------ 79 | 80 | There is a Grafana dashboard available at https://grafana.com/grafana/dashboards/13983-fritz-exporter/. 81 | If the host info metrics are enabled a dashboard also using those metrics is available at https://grafana.com/grafana/dashboards/17751-fritz-exporter-dash/. 82 | 83 | Helm Chart 84 | ---------- 85 | 86 | There is a (rather crude) Helm chart under ``helm`` in the `repository `_. It will deploy the exporter and also create a service monitor for Prometheus Operator to automatically scrape the exporter. 87 | 88 | Helping out 89 | ----------- 90 | 91 | If your device delivers some metrics which are not yet scraped by this exporter you can either create a Pull Request, which will be gladly accepted ;-) 92 | 93 | Alternatively you can use the following commands and the little helper script in the root of this repository to let me know of metrics: 94 | 95 | .. code-block:: bash 96 | 97 | fritzconnection -i -s # Lists available services 98 | fritzconnection -i -S # Lists available action for a service 99 | python -m fritz_export_helper -s -a # Will output the data returned from the device in a readable format 100 | 101 | If you have found something you need/want, open an issue provide the following infos: 102 | 103 | 1. Model of Device (e.g. FritzBox DSL 7590) 104 | 2. ServiceName and ActionName 105 | 3. Output from fritz_export_helper (make sure, there is not secret data, like password or WiFi passwords in there, before sending!) 106 | 4. What output you need/want and a little bit of info of the environment (Cable, DSL, Hybrid LTE Mode, ... whatever might be relevant) 107 | 108 | 109 | Disclaimer 110 | ---------- 111 | 112 | Fritz! and AVM are registered trademarks of AVM GmbH. This project is not associated with AVM or Fritz other than using the devices and their names to refer to them. 113 | 114 | Copyright 115 | --------- 116 | 117 | Copyright 2019-2025 Patrick Dreker 118 | 119 | Licensed under the Apache License, Version 2.0 (the "License"); 120 | you may not use this file except in compliance with the License. 121 | You may obtain a copy of the License at 122 | 123 | 124 | 125 | Unless required by applicable law or agreed to in writing, software 126 | distributed under the License is distributed on an "AS IS" BASIS, 127 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 128 | See the License for the specific language governing permissions and 129 | limitations under the License. 130 | 131 | 132 | 133 | Indices and tables 134 | ================== 135 | 136 | * :ref:`genindex` 137 | * :ref:`modindex` 138 | * :ref:`search` 139 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [2.2.1](https://github.com/pdreker/fritz_exporter/compare/v2.2.1-pre.6...v2.2.1) (2022-12-29) 2 | 3 | 4 | 5 | ## [2.6.1](https://github.com/pdreker/fritz_exporter/compare/fritzexporter-v2.6.0...fritzexporter-v2.6.1) (2025-11-30) 6 | 7 | 8 | ### Bug Fixes 9 | 10 | * enhance security warning for listen_address configuration and add deprecation warning ([#532](https://github.com/pdreker/fritz_exporter/issues/532)) ([29bccbc](https://github.com/pdreker/fritz_exporter/commit/29bccbccca19b0fe8a2470d0df8508647684a7b2)) 11 | * handle FritzAuthorizationError in FritzDevice connection ([#531](https://github.com/pdreker/fritz_exporter/issues/531)) ([8e16d1c](https://github.com/pdreker/fritz_exporter/commit/8e16d1c70e91017fda7ddd2f6391f53ed1a86cff)) 12 | * make FritzCollector.collect() safe against multiple parallel runs ([#529](https://github.com/pdreker/fritz_exporter/issues/529)) ([9395df2](https://github.com/pdreker/fritz_exporter/commit/9395df2ad2f612238db6960f2a591b208b9e7e59)) 13 | 14 | ## [2.6.0](https://github.com/pdreker/fritz_exporter/compare/fritzexporter-v2.5.2...fritzexporter-v2.6.0) (2025-10-03) 15 | 16 | 17 | ### Features 18 | 19 | * add fritz_connection_mode metric(fixes [#458](https://github.com/pdreker/fritz_exporter/issues/458)) ([#459](https://github.com/pdreker/fritz_exporter/issues/459)) ([f0899ce](https://github.com/pdreker/fritz_exporter/commit/f0899ce0441c86b45c6e5a3344d51f3ac41de563)) 20 | 21 | ## [2.5.2](https://github.com/pdreker/fritz_exporter/compare/fritzexporter-v2.5.1...fritzexporter-v2.5.2) (2025-02-16) 22 | 23 | 24 | ### Bug Fixes 25 | 26 | * **docs:** minimize Fritz!Box user permissions ([#436](https://github.com/pdreker/fritz_exporter/issues/436)) ([b137062](https://github.com/pdreker/fritz_exporter/commit/b13706253e6ee08120988ef7201b1f1b08132ff4)) 27 | 28 | ## [2.5.1](https://github.com/pdreker/fritz_exporter/compare/fritzexporter-v2.5.0...fritzexporter-v2.5.1) (2024-11-11) 29 | 30 | 31 | ### Bug Fixes 32 | 33 | * catch FritzConnectionException when checking capabilities ([191869d](https://github.com/pdreker/fritz_exporter/commit/191869d02080753206d29b57d5072caee2fcc2c4)) 34 | 35 | ## [2.5.0](https://github.com/pdreker/fritz_exporter/compare/fritzexporter-v2.4.3...fritzexporter-v2.5.0) (2024-03-13) 36 | 37 | 38 | ### Features 39 | 40 | * make listen address configurable (by @NyCodeGHG in [#315](https://github.com/pdreker/fritz_exporter/issues/315)) ([#316](https://github.com/pdreker/fritz_exporter/issues/316)) ([abc1671](https://github.com/pdreker/fritz_exporter/commit/abc1671dd74d6d4e480ec5747e12b08b1ffd0609)) 41 | 42 | ## [2.4.3](https://github.com/pdreker/fritz_exporter/compare/fritzexporter-v2.4.2...fritzexporter-v2.4.3) (2024-03-10) 43 | 44 | 45 | ### Bug Fixes 46 | 47 | * minor linting, make ARM builds work again ([#308](https://github.com/pdreker/fritz_exporter/issues/308)) ([1632029](https://github.com/pdreker/fritz_exporter/commit/16320292dc1ea5c1fba1f5d6dc4a5bb05467f579)) 48 | 49 | ## [2.4.2](https://github.com/pdreker/fritz_exporter/compare/fritzexporter-v2.4.1...fritzexporter-v2.4.2) (2024-03-10) 50 | 51 | 52 | ### Documentation 53 | 54 | * update copyright notice years ([0d19d27](https://github.com/pdreker/fritz_exporter/commit/0d19d27e4fc868d234c30c368f1aa8cb350866fd)) 55 | * Update Docker build instruction. ([65064f4](https://github.com/pdreker/fritz_exporter/commit/65064f47c446a88a1470f082393452b982af4234)) 56 | 57 | ## [2.4.1](https://github.com/pdreker/fritz_exporter/compare/fritzexporter-v2.4.0...fritzexporter-v2.4.1) (2024-03-10) 58 | 59 | 60 | ### Bug Fixes 61 | 62 | * ignore exceptions from parsing XML of AHA devices ([#303](https://github.com/pdreker/fritz_exporter/issues/303)) ([02197fa](https://github.com/pdreker/fritz_exporter/commit/02197fab4bb74eff8488ae03b84008535735c883)) 63 | 64 | ## [2.4.0](https://github.com/pdreker/fritz_exporter/compare/fritzexporter-v2.3.1...fritzexporter-v2.4.0) (2024-03-09) 65 | 66 | 67 | ### Features 68 | 69 | * add Homeautomation metrics via HTTP ([#273](https://github.com/pdreker/fritz_exporter/issues/273)) ([72f1361](https://github.com/pdreker/fritz_exporter/commit/72f136160943f4e9f3a9feec7c4d156af2b5e4cd)) 70 | * allow reading password from a file ([#296](https://github.com/pdreker/fritz_exporter/issues/296)) ([369f007](https://github.com/pdreker/fritz_exporter/commit/369f007f0543dffb6170bd16557a06c554d824bd)) 71 | 72 | 73 | ### Bug Fixes 74 | 75 | * Add more flexibility to helper script. ([#280](https://github.com/pdreker/fritz_exporter/issues/280)) ([86697fa](https://github.com/pdreker/fritz_exporter/commit/86697fa075c980530c9c45c6822cab9d44579a2e)) 76 | * **helm:** ServiceMonitor seems to like quotes now ([2e57ee9](https://github.com/pdreker/fritz_exporter/commit/2e57ee98d035cb27add47790852f29415d97b008)) 77 | * small correction for AHA HTTP metrics ([6658b8a](https://github.com/pdreker/fritz_exporter/commit/6658b8ad55374e00741a6dbc15ae709d99d43c30)) 78 | 79 | ## [2.3.1](https://github.com/pdreker/fritz_exporter/compare/fritzexporter-v2.3.0...fritzexporter-v2.3.1) (2023-12-16) 80 | 81 | 82 | ### Bug Fixes 83 | 84 | * add type hints to data_donation.py ([369748a](https://github.com/pdreker/fritz_exporter/commit/369748a2fb1672b01bf1d9b44845b0368d989c28)) 85 | 86 | 87 | ### Documentation 88 | 89 | * **homeauto:** explicitly mention missing metrics ([d827b5d](https://github.com/pdreker/fritz_exporter/commit/d827b5daa4d8f61fcbcf99c9df08ba6005992e77)) 90 | 91 | ## [2.3.0](https://github.com/pdreker/fritz_exporter/compare/v2.2.4...v2.3.0) (2023-10-03) 92 | 93 | 94 | ### Features 95 | 96 | * add home automation metrics ([5f35afb](https://github.com/pdreker/fritz_exporter/commit/5f35afb335ccc84333bb9219185e7409e051f47a)) 97 | 98 | 99 | ### Bug Fixes 100 | 101 | * add .readthedocs.yaml config file ([df7b150](https://github.com/pdreker/fritz_exporter/commit/df7b150f00f0542559a745fc02c4b40b4557c34a)) 102 | * make bail out message less verbose ([724fd01](https://github.com/pdreker/fritz_exporter/commit/724fd011a55485b7ddab3f69061fbdb2eb34326f)) 103 | 104 | 105 | ### Documentation 106 | 107 | * update copyright boilerplate ([baafe01](https://github.com/pdreker/fritz_exporter/commit/baafe01d6a8fffbe241db03b6fa813f4248ddb8d)) 108 | 109 | ## [2.2.4](https://github.com/pdreker/fritz_exporter/compare/v2.2.3...v2.2.4) (2023-08-12) 110 | 111 | 112 | ### Bug Fixes 113 | 114 | * returning the same metric samples multiple times ([020a7b7](https://github.com/pdreker/fritz_exporter/commit/020a7b78e893bd03778f67e43e37b94cc02aad92)) 115 | * When checking for capabilities fritzconnection may return FritzArgumentError which needs to be caught. ([87a47ee](https://github.com/pdreker/fritz_exporter/commit/87a47ee5fcf19e9cdb186f89c8230c443b07ccce)) 116 | 117 | ## [2.2.3](https://github.com/pdreker/fritz_exporter/compare/v2.2.2...v2.2.3) (2023-04-01) 118 | 119 | 120 | ### Bug Fixes 121 | 122 | * rmeove old actions and supersede release 2.2.2 ([1be640b](https://github.com/pdreker/fritz_exporter/commit/1be640b3a692a1402c1c4f85a3db6297b034fe01)) 123 | 124 | ## [2.2.2](https://github.com/pdreker/fritz_exporter/compare/v2.2.1...v2.2.2) (2023-04-01) 125 | 126 | 127 | ### Bug Fixes 128 | 129 | * **helm:** corrected secret to convert map to YAML string ([3814de2](https://github.com/pdreker/fritz_exporter/commit/3814de2b727670537f77a9cab36e786755a58ec5)) 130 | * **helm:** set interval to 60s ([d3f8854](https://github.com/pdreker/fritz_exporter/commit/d3f8854f5d423be8692ebf14b1413b11e647af20)) 131 | * **helm:** using selectorLabels var in ServiceMonitor ([6ecba54](https://github.com/pdreker/fritz_exporter/commit/6ecba54276e9cffbe1b20afc311d9240878adf55)) 132 | * remove need for _version.py ([434581b](https://github.com/pdreker/fritz_exporter/commit/434581ba027fd62d09b097af6c2f4fa813f294a5)) 133 | * sanitize (WANIPConnection1, GetInfo, NewExternalIPAddress) ([ebdf787](https://github.com/pdreker/fritz_exporter/commit/ebdf7874b0d3660b184e4d31342580c5114d564d)) 134 | 135 | 136 | ### Documentation 137 | 138 | * add link to dashboard also displaying host info metrics ([f6fd853](https://github.com/pdreker/fritz_exporter/commit/f6fd85381b4db79c436d5c8b0ff8f8e537ffea21)) 139 | * Add reference to PyPI in docs ([d53b03a](https://github.com/pdreker/fritz_exporter/commit/d53b03aa1fee864f3f8d539b90b933d7c2c5e5a8)) 140 | * fix badges in README ([685e5d2](https://github.com/pdreker/fritz_exporter/commit/685e5d20085c80d435fa232390593b7ec507d146)) 141 | * reduce warnings/caveats for "host_info" ([f614d5b](https://github.com/pdreker/fritz_exporter/commit/f614d5bd0db2ed8abd8f2782be079965e11a304e)) 142 | 143 | ## [2.2.1-pre.6](https://github.com/pdreker/fritz_exporter/compare/v2.2.1-pre.5...v2.2.1-pre.6) (2022-12-29) 144 | 145 | 146 | 147 | ## [2.2.1-pre.5](https://github.com/pdreker/fritz_exporter/compare/v2.2.1-pre.4...v2.2.1-pre.5) (2022-12-29) 148 | 149 | 150 | 151 | ## [2.2.1-pre.4](https://github.com/pdreker/fritz_exporter/compare/v2.2.1-pre.3...v2.2.1-pre.4) (2022-12-29) 152 | 153 | 154 | 155 | ## [2.2.1-pre.3](https://github.com/pdreker/fritz_exporter/compare/v2.2.1-pre.2...v2.2.1-pre.3) (2022-12-29) 156 | 157 | 158 | ### Bug Fixes 159 | 160 | * exporter does not honor log_level from config file. ([ad93945](https://github.com/pdreker/fritz_exporter/commit/ad93945eac60c780044946999d79735d0399d0f0)), closes [#116](https://github.com/pdreker/fritz_exporter/issues/116) 161 | -------------------------------------------------------------------------------- /tests/test_datadonation.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from unittest.mock import MagicMock, call, patch 3 | 4 | import pytest 5 | import requests 6 | from fritzconnection.core.exceptions import ( 7 | FritzActionError, 8 | FritzArgumentError, 9 | FritzConnectionException, 10 | FritzServiceError, 11 | ) 12 | 13 | from fritzexporter.data_donation import ( 14 | donate_data, 15 | get_sw_version, 16 | safe_call_action, 17 | sanitize_results, 18 | ) 19 | from fritzexporter.fritzdevice import FritzCredentials, FritzDevice 20 | 21 | from .fc_services_mock import ( 22 | call_action_mock, 23 | call_action_no_basic_action_error_mock, 24 | call_action_no_basic_mock, 25 | create_fc_services, 26 | fc_services_capabilities, 27 | fc_services_devices, 28 | fc_services_no_basic_info, 29 | ) 30 | 31 | 32 | class MockResponse: 33 | def __init__(self, json_data, status_code): 34 | self.json_data = json_data 35 | self.status_code = status_code 36 | 37 | def json(self): 38 | return self.json_data 39 | 40 | def raise_for_status(self): 41 | raise requests.exceptions.HTTPError 42 | 43 | 44 | @patch("fritzexporter.fritzdevice.FritzConnection") 45 | class TestDataDonation: 46 | def test_should_return_sw_version(self, mock_fritzconnection: MagicMock, caplog): 47 | # Prepare 48 | caplog.set_level(logging.DEBUG) 49 | 50 | fc = mock_fritzconnection.return_value 51 | fc.call_action.side_effect = call_action_mock 52 | fc.services = create_fc_services(fc_services_capabilities["DeviceInfo"]) 53 | 54 | # Act 55 | fd = FritzDevice(FritzCredentials("somehost", "someuser", "password"), "FritzMock", host_info=False) 56 | version = get_sw_version(fd) 57 | 58 | # Check 59 | assert version == "1.2" 60 | 61 | def test_should_return_fritz_service_error(self, mock_fritzconnection: MagicMock, caplog): 62 | # Prepare 63 | caplog.set_level(logging.DEBUG) 64 | 65 | fc = mock_fritzconnection.return_value 66 | fc.call_action.side_effect = call_action_no_basic_mock 67 | fc.services = create_fc_services(fc_services_no_basic_info) 68 | 69 | # Act 70 | fd = FritzDevice(FritzCredentials("somehost", "someuser", "password"), "FritzMock", host_info=False) 71 | version = get_sw_version(fd) 72 | 73 | # Check 74 | assert "ERROR - FritzServiceError:" in version 75 | 76 | def test_should_return_fritz_action_error(self, mock_fritzconnection: MagicMock, caplog): 77 | # Prepare 78 | caplog.set_level(logging.DEBUG) 79 | 80 | fc = mock_fritzconnection.return_value 81 | fc.call_action.side_effect = call_action_no_basic_action_error_mock 82 | fc.services = create_fc_services(fc_services_no_basic_info) 83 | 84 | # Act 85 | fd = FritzDevice(FritzCredentials("somehost", "someuser", "password"), "FritzMock", host_info=False) 86 | version = get_sw_version(fd) 87 | 88 | # Check 89 | assert "ERROR - FritzActionError:" in version 90 | 91 | @pytest.mark.parametrize( 92 | "exception", 93 | [FritzServiceError, FritzActionError, FritzArgumentError, FritzConnectionException], 94 | ) 95 | def test_should_not_raise_exceptions(self, mock_fritzconnection: MagicMock, exception, caplog): 96 | # Prepare 97 | caplog.set_level(logging.DEBUG) 98 | 99 | fc = mock_fritzconnection.return_value 100 | fc.call_action.side_effect = call_action_mock 101 | fc.services = create_fc_services(fc_services_no_basic_info) 102 | 103 | # Act 104 | fd = FritzDevice(FritzCredentials("somehost", "someuser", "password"), "FritzMock", host_info=False) 105 | fc.call_action.side_effect = exception 106 | res = safe_call_action(fd, "foo", "bar") 107 | 108 | # Check 109 | assert "error" in res 110 | 111 | def test_should_return_blacklisted(self, mock_fritzconnection: MagicMock, caplog): 112 | # Prepare 113 | caplog.set_level(logging.DEBUG) 114 | 115 | fc = mock_fritzconnection.return_value 116 | fc.call_action.side_effect = call_action_mock 117 | fc.services = create_fc_services(fc_services_devices["FritzBox 7590"]) 118 | 119 | # Act 120 | fd = FritzDevice(FritzCredentials("somehost", "someuser", "password"), "FritzMock", host_info=False) 121 | res = safe_call_action(fd, "DeviceConfig1", "GetPersistentData") 122 | 123 | # Check 124 | assert res == {"error": ""} 125 | 126 | def test_should_sanitize_data(self, caplog): 127 | # Prepare 128 | caplog.set_level(logging.DEBUG) 129 | 130 | input_data = { 131 | ("WLANConfiguration4", "X_AVM-DE_GetWLANHybridMode"): { 132 | "NewBSSID": "foobar", 133 | "foo": "bar", 134 | } 135 | } 136 | 137 | # Act 138 | output = sanitize_results(input_data, sanitation=[]) 139 | 140 | # Check 141 | assert ( 142 | output[("WLANConfiguration4", "X_AVM-DE_GetWLANHybridMode")]["NewBSSID"] 143 | == "" 144 | ) 145 | assert output[("WLANConfiguration4", "X_AVM-DE_GetWLANHybridMode")]["foo"] == "bar" 146 | 147 | def test_should_not_sanitize_data(self, caplog): 148 | # Prepare 149 | caplog.set_level(logging.DEBUG) 150 | 151 | input_data = { 152 | ("foo", "bar"): { 153 | "baz": "foobar", 154 | "quux": "baz", 155 | } 156 | } 157 | 158 | # Act 159 | output = sanitize_results(input_data, sanitation=[]) 160 | 161 | # Check 162 | assert output == input_data 163 | 164 | def test_should_custom_sanitize_field(self, caplog): 165 | # Prepare 166 | caplog.set_level(logging.DEBUG) 167 | 168 | input_data = { 169 | ("foo", "bar"): { 170 | "baz": "foobar", 171 | "quux": "baz", 172 | } 173 | } 174 | 175 | expected = { 176 | ("foo", "bar"): { 177 | "baz": "", 178 | "quux": "baz", 179 | } 180 | } 181 | 182 | # Act 183 | output = sanitize_results(input_data, sanitation=[["foo", "bar", "baz"]]) 184 | 185 | # Check 186 | assert output == expected 187 | 188 | def test_should_custom_sanitize_action(self, caplog): 189 | # Prepare 190 | caplog.set_level(logging.DEBUG) 191 | 192 | input_data = { 193 | ("foo", "bar"): { 194 | "baz": "foobar", 195 | "quux": "baz", 196 | } 197 | } 198 | 199 | expected = { 200 | ("foo", "bar"): { 201 | "baz": "", 202 | "quux": "", 203 | } 204 | } 205 | 206 | # Act 207 | output = sanitize_results(input_data, sanitation=[["foo", "bar"]]) 208 | 209 | # Check 210 | assert output == expected 211 | 212 | @patch("fritzexporter.data_donation.requests.post") 213 | def test_should_produce_sensible_json_data_and_upload( 214 | self, mock_requests_post: MagicMock, mock_fritzconnection: MagicMock, caplog 215 | ): 216 | # Prepare 217 | caplog.set_level(logging.DEBUG) 218 | 219 | fc = mock_fritzconnection.return_value 220 | fc.call_action.side_effect = call_action_mock 221 | fc.services = create_fc_services(fc_services_capabilities["HostNumberOfEntries"]) 222 | 223 | # Act 224 | fd = FritzDevice(FritzCredentials("somehost", "someuser", "password"), "FritzMock", host_info=False) 225 | donate_data(fd, upload=True) 226 | 227 | # check 228 | assert mock_requests_post.call_count == 1 229 | assert mock_requests_post.call_args == call( 230 | "https://fritz.dreker.de/data/donate", 231 | data='{"exporter_version": "develop", "fritzdevice": {"model": "Fritz!MockBox 9790", ' 232 | '"os_version": "1.2", "services": {"Hosts1": ["GetHostNumberOfEntries"]}, ' 233 | '"detected_capabilities": ["DeviceInfo", "HostNumberOfEntries", "UserInterface", ' 234 | '"LanInterfaceConfig", "LanInterfaceConfigStatistics", "WanDSLInterfaceConfig", ' 235 | '"WanDSLInterfaceConfigAVM", "WanPPPConnectionStatus", "WanCommonInterfaceConfig", ' 236 | '"WanCommonInterfaceDataBytes", "WanCommonInterfaceByteRate", ' 237 | '"WanCommonInterfaceDataPackets", "WlanConfigurationInfo", "HostInfo", ' 238 | '"HomeAutomation"], "action_results": {"Hosts1": {"GetHostNumberOfEntries": ' 239 | '{"NewHostNumberOfEntries": "3"}}}}}', 240 | headers={"Content-Type": "application/json"},timeout=10, 241 | ) 242 | 243 | @patch("fritzexporter.data_donation.requests.post") 244 | def test_should_produce_sensible_json_data_and_not_upload( 245 | self, mock_requests_post: MagicMock, mock_fritzconnection: MagicMock, caplog 246 | ): 247 | # Prepare 248 | caplog.set_level(logging.DEBUG) 249 | 250 | fc = mock_fritzconnection.return_value 251 | fc.call_action.side_effect = call_action_mock 252 | fc.services = create_fc_services(fc_services_capabilities["HostNumberOfEntries"]) 253 | 254 | # Act 255 | fd = FritzDevice(FritzCredentials("somehost", "someuser", "password"), "FritzMock", host_info=False) 256 | donate_data(fd) 257 | 258 | # check 259 | assert mock_requests_post.call_count == 0 260 | 261 | @patch( 262 | "fritzexporter.data_donation.requests.post", 263 | side_effect=[ 264 | MockResponse({"donation_id": "1234-12345678-12345678-1234"}, 200), 265 | MockResponse({"error": "Unprocessable Entity"}, 422), 266 | ], 267 | ) 268 | def test_should_log_success_with_id( 269 | self, mock_requests_post: MagicMock, mock_fritzconnection: MagicMock, caplog 270 | ): 271 | # Prepare 272 | caplog.set_level(logging.DEBUG) 273 | 274 | fc = mock_fritzconnection.return_value 275 | fc.call_action.side_effect = call_action_mock 276 | fc.services = create_fc_services(fc_services_capabilities["HostNumberOfEntries"]) 277 | 278 | # Act 1 279 | fd = FritzDevice(FritzCredentials("somehost", "someuser", "password"), "FritzMock", host_info=False) 280 | donate_data(fd, upload=True) 281 | 282 | # Check 1 283 | assert ( 284 | "Data donation for device Fritz!MockBox 9790 registered under id " 285 | "1234-12345678-12345678-1234" in caplog.text 286 | ) 287 | 288 | # Act 2 289 | with pytest.raises(requests.exceptions.HTTPError): 290 | donate_data(fd, upload=True) 291 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Apache License 2 | ============== 3 | 4 | _Version 2.0, January 2004_ 5 | _<>_ 6 | 7 | ### Terms and Conditions for use, reproduction, and distribution 8 | 9 | #### 1. Definitions 10 | 11 | “License” shall mean the terms and conditions for use, reproduction, and 12 | distribution as defined by Sections 1 through 9 of this document. 13 | 14 | “Licensor” shall mean the copyright owner or entity authorized by the copyright 15 | owner that is granting the License. 16 | 17 | “Legal Entity” shall mean the union of the acting entity and all other entities 18 | that control, are controlled by, or are under common control with that entity. 19 | For the purposes of this definition, “control” means **(i)** the power, direct or 20 | indirect, to cause the direction or management of such entity, whether by 21 | contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the 22 | outstanding shares, or **(iii)** beneficial ownership of such entity. 23 | 24 | “You” (or “Your”) shall mean an individual or Legal Entity exercising 25 | permissions granted by this License. 26 | 27 | “Source” form shall mean the preferred form for making modifications, including 28 | but not limited to software source code, documentation source, and configuration 29 | files. 30 | 31 | “Object” form shall mean any form resulting from mechanical transformation or 32 | translation of a Source form, including but not limited to compiled object code, 33 | generated documentation, and conversions to other media types. 34 | 35 | “Work” shall mean the work of authorship, whether in Source or Object form, made 36 | available under the License, as indicated by a copyright notice that is included 37 | in or attached to the work (an example is provided in the Appendix below). 38 | 39 | “Derivative Works” shall mean any work, whether in Source or Object form, that 40 | is based on (or derived from) the Work and for which the editorial revisions, 41 | annotations, elaborations, or other modifications represent, as a whole, an 42 | original work of authorship. For the purposes of this License, Derivative Works 43 | shall not include works that remain separable from, or merely link (or bind by 44 | name) to the interfaces of, the Work and Derivative Works thereof. 45 | 46 | “Contribution” shall mean any work of authorship, including the original version 47 | of the Work and any modifications or additions to that Work or Derivative Works 48 | thereof, that is intentionally submitted to Licensor for inclusion in the Work 49 | by the copyright owner or by an individual or Legal Entity authorized to submit 50 | on behalf of the copyright owner. For the purposes of this definition, 51 | “submitted” means any form of electronic, verbal, or written communication sent 52 | to the Licensor or its representatives, including but not limited to 53 | communication on electronic mailing lists, source code control systems, and 54 | issue tracking systems that are managed by, or on behalf of, the Licensor for 55 | the purpose of discussing and improving the Work, but excluding communication 56 | that is conspicuously marked or otherwise designated in writing by the copyright 57 | owner as “Not a Contribution.” 58 | 59 | “Contributor” shall mean Licensor and any individual or Legal Entity on behalf 60 | of whom a Contribution has been received by Licensor and subsequently 61 | incorporated within the Work. 62 | 63 | #### 2. Grant of Copyright License 64 | 65 | Subject to the terms and conditions of this License, each Contributor hereby 66 | grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, 67 | irrevocable copyright license to reproduce, prepare Derivative Works of, 68 | publicly display, publicly perform, sublicense, and distribute the Work and such 69 | Derivative Works in Source or Object form. 70 | 71 | #### 3. Grant of Patent License 72 | 73 | Subject to the terms and conditions of this License, each Contributor hereby 74 | grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, 75 | irrevocable (except as stated in this section) patent license to make, have 76 | made, use, offer to sell, sell, import, and otherwise transfer the Work, where 77 | such license applies only to those patent claims licensable by such Contributor 78 | that are necessarily infringed by their Contribution(s) alone or by combination 79 | of their Contribution(s) with the Work to which such Contribution(s) was 80 | submitted. If You institute patent litigation against any entity (including a 81 | cross-claim or counterclaim in a lawsuit) alleging that the Work or a 82 | Contribution incorporated within the Work constitutes direct or contributory 83 | patent infringement, then any patent licenses granted to You under this License 84 | for that Work shall terminate as of the date such litigation is filed. 85 | 86 | #### 4. Redistribution 87 | 88 | You may reproduce and distribute copies of the Work or Derivative Works thereof 89 | in any medium, with or without modifications, and in Source or Object form, 90 | provided that You meet the following conditions: 91 | 92 | * **(a)** You must give any other recipients of the Work or Derivative Works a copy of 93 | this License; and 94 | * **(b)** You must cause any modified files to carry prominent notices stating that You 95 | changed the files; and 96 | * **(c)** You must retain, in the Source form of any Derivative Works that You distribute, 97 | all copyright, patent, trademark, and attribution notices from the Source form 98 | of the Work, excluding those notices that do not pertain to any part of the 99 | Derivative Works; and 100 | * **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any 101 | Derivative Works that You distribute must include a readable copy of the 102 | attribution notices contained within such NOTICE file, excluding those notices 103 | that do not pertain to any part of the Derivative Works, in at least one of the 104 | following places: within a NOTICE text file distributed as part of the 105 | Derivative Works; within the Source form or documentation, if provided along 106 | with the Derivative Works; or, within a display generated by the Derivative 107 | Works, if and wherever such third-party notices normally appear. The contents of 108 | the NOTICE file are for informational purposes only and do not modify the 109 | License. You may add Your own attribution notices within Derivative Works that 110 | You distribute, alongside or as an addendum to the NOTICE text from the Work, 111 | provided that such additional attribution notices cannot be construed as 112 | modifying the License. 113 | 114 | You may add Your own copyright statement to Your modifications and may provide 115 | additional or different license terms and conditions for use, reproduction, or 116 | distribution of Your modifications, or for any such Derivative Works as a whole, 117 | provided Your use, reproduction, and distribution of the Work otherwise complies 118 | with the conditions stated in this License. 119 | 120 | #### 5. Submission of Contributions 121 | 122 | Unless You explicitly state otherwise, any Contribution intentionally submitted 123 | for inclusion in the Work by You to the Licensor shall be under the terms and 124 | conditions of this License, without any additional terms or conditions. 125 | Notwithstanding the above, nothing herein shall supersede or modify the terms of 126 | any separate license agreement you may have executed with Licensor regarding 127 | such Contributions. 128 | 129 | #### 6. Trademarks 130 | 131 | This License does not grant permission to use the trade names, trademarks, 132 | service marks, or product names of the Licensor, except as required for 133 | reasonable and customary use in describing the origin of the Work and 134 | reproducing the content of the NOTICE file. 135 | 136 | #### 7. Disclaimer of Warranty 137 | 138 | Unless required by applicable law or agreed to in writing, Licensor provides the 139 | Work (and each Contributor provides its Contributions) on an “AS IS” BASIS, 140 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, 141 | including, without limitation, any warranties or conditions of TITLE, 142 | NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are 143 | solely responsible for determining the appropriateness of using or 144 | redistributing the Work and assume any risks associated with Your exercise of 145 | permissions under this License. 146 | 147 | #### 8. Limitation of Liability 148 | 149 | In no event and under no legal theory, whether in tort (including negligence), 150 | contract, or otherwise, unless required by applicable law (such as deliberate 151 | and grossly negligent acts) or agreed to in writing, shall any Contributor be 152 | liable to You for damages, including any direct, indirect, special, incidental, 153 | or consequential damages of any character arising as a result of this License or 154 | out of the use or inability to use the Work (including but not limited to 155 | damages for loss of goodwill, work stoppage, computer failure or malfunction, or 156 | any and all other commercial damages or losses), even if such Contributor has 157 | been advised of the possibility of such damages. 158 | 159 | #### 9. Accepting Warranty or Additional Liability 160 | 161 | While redistributing the Work or Derivative Works thereof, You may choose to 162 | offer, and charge a fee for, acceptance of support, warranty, indemnity, or 163 | other liability obligations and/or rights consistent with this License. However, 164 | in accepting such obligations, You may act only on Your own behalf and on Your 165 | sole responsibility, not on behalf of any other Contributor, and only if You 166 | agree to indemnify, defend, and hold each Contributor harmless for any liability 167 | incurred by, or claims asserted against, such Contributor by reason of your 168 | accepting any such warranty or additional liability. 169 | 170 | _END OF TERMS AND CONDITIONS_ 171 | 172 | ### APPENDIX: How to apply the Apache License to your work 173 | 174 | To apply the Apache License to your work, attach the following boilerplate 175 | notice, with the fields enclosed by brackets `[]` replaced with your own 176 | identifying information. (Don't include the brackets!) The text should be 177 | enclosed in the appropriate comment syntax for the file format. We also 178 | recommend that a file or class name and description of purpose be included on 179 | the same “printed page” as the copyright notice for easier identification within 180 | third-party archives. 181 | 182 | Copyright [yyyy] [name of copyright owner] 183 | 184 | Licensed under the Apache License, Version 2.0 (the "License"); 185 | you may not use this file except in compliance with the License. 186 | You may obtain a copy of the License at 187 | 188 | http://www.apache.org/licenses/LICENSE-2.0 189 | 190 | Unless required by applicable law or agreed to in writing, software 191 | distributed under the License is distributed on an "AS IS" BASIS, 192 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 193 | See the License for the specific language governing permissions and 194 | limitations under the License. 195 | 196 | -------------------------------------------------------------------------------- /fritzexporter/data_donation.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import logging 5 | import os 6 | from typing import Any 7 | 8 | import requests 9 | from fritzconnection.core.exceptions import ( # type: ignore[import] 10 | FritzActionError, 11 | FritzArgumentError, 12 | FritzConnectionException, 13 | FritzServiceError, 14 | ) 15 | 16 | from . import __version__ 17 | from .action_blacklists import BlacklistItem, call_blacklist 18 | from .fritzdevice import FritzDevice 19 | 20 | logger = logging.getLogger("fritzexporter.donate_data") 21 | 22 | 23 | def get_sw_version(device: FritzDevice) -> str: 24 | try: 25 | info_result = device.fc.call_action("DeviceInfo1", "GetInfo") 26 | except FritzServiceError as e: 27 | return f"ERROR - FritzServiceError: {e}" 28 | except FritzActionError as e: 29 | return f"ERROR - FritzActionError: {e}" 30 | 31 | return info_result["NewSoftwareVersion"] 32 | 33 | 34 | def safe_call_action(device: FritzDevice, service: str, action: str) -> dict[str, str]: 35 | if BlacklistItem(service, action) in call_blacklist: 36 | return {"error": ""} 37 | 38 | try: 39 | result = device.fc.call_action(service, action) 40 | except (FritzServiceError, FritzActionError, FritzArgumentError, FritzConnectionException) as e: 41 | result = {"error": f"{e}"} 42 | return result 43 | 44 | 45 | def sanitize_results( 46 | res: dict[tuple[str, str], dict], sanitation: list[list] 47 | ) -> dict[tuple[str, str], dict[str, Any]]: 48 | blacklist: dict[tuple[str, str], list[str]] = { 49 | # (service, action): return_value 50 | ("DeviceConfig1", "GetPersistentData"): ["NewPersistentData"], 51 | ("DeviceConfig1", "X_AVM-DE_GetConfigFile"): ["NewConfigFile"], 52 | ("DeviceInfo1", "GetDeviceLog"): ["NewDeviceLog"], 53 | ("DeviceConfig1", "X_AVM-DE_GetSupportDataInfo"): ["NewX_AVM-DE_SupportDataID"], 54 | ("DeviceInfo1", "GetInfo"): ["NewDeviceLog", "NewProvisioningCode", "NewSerialNumber"], 55 | ("DeviceInfo1", "GetSecurityPort"): ["NewSecurityPort"], 56 | ("Hosts1", "X_AVM-DE_GetHostListPath"): ["NewX_AVM-DE_HostListPath"], 57 | ("Hosts1", "X_AVM-DE_GetMeshListPath"): ["NewX_AVM-DE_MeshListPath"], 58 | ("LANConfigSecurity1", "X_AVM-DE_GetCurrentUser"): [ 59 | "NewX_AVM-DE_CurrentUserRights", 60 | "NewX_AVM-DE_CurrentUsername", 61 | ], 62 | ("LANConfigSecurity1", "X_AVM-DE_GetUserList"): ["NewX_AVM-DE_UserList"], 63 | ("LANEthernetInterfaceConfig1", "GetInfo"): ["NewMACAddress"], 64 | ("LANHostConfigManagement1", "GetAddressRange"): ["NewMaxAddress", "NewMinAddress"], 65 | ("LANHostConfigManagement1", "GetDNSServers"): ["NewDNSServers"], 66 | ("LANHostConfigManagement1", "GetIPRoutersList"): ["NewIPRouters"], 67 | ("LANHostConfigManagement1", "GetInfo"): [ 68 | "NewDNSServers", 69 | "NewIPRouters", 70 | "NewMaxAddress", 71 | "NewMinAddress", 72 | ], 73 | ("ManagementServer1", "GetInfo"): ["NewUsername", "NewConnectionRequestURL"], 74 | ("Time1", "GetInfo"): ["NewNTPServer1", "NewNTPServer2"], 75 | ("WANCommonIFC1", "GetAddonInfos"): [ 76 | "NewDNSServer1", 77 | "NewDNSServer2", 78 | "NewVoipDNSServer1", 79 | "NewVoipDNSServer2", 80 | ], 81 | ("WANIPConn1", "GetExternalIPAddress"): ["NewExternalIPAddress"], 82 | ("WANIPConn1", "X_AVM_DE_GetDNSServer"): ["NewIPv4DNSServer1", "NewIPv4DNSServer2"], 83 | ("WANIPConn1", "X_AVM_DE_GetExternalIPv6Address"): ["NewExternalIPv6Address"], 84 | ("WANIPConn1", "X_AVM_DE_GetIPv6DNSServer"): ["NewIPv6DNSServer1", "NewIPv6DNSServer2"], 85 | ("WANIPConn1", "X_AVM_DE_GetIPv6Prefix"): ["NewIPv6Prefix"], 86 | ("WANIPConnection1", "GetExternalIPAddress"): ["NewExternalIPAddress"], 87 | ("WANIPConnection1", "GetInfo"): ["NewDNSServers", "NewMACAddress", "NewExternalIPAddress"], 88 | ("WANIPConnection1", "X_GetDNSServers"): ["NewDNSServers"], 89 | ("WANPPPConnection1", "GetExternalIPAddress"): ["NewExternalIPAddress"], 90 | ("WANPPPConnection1", "GetInfo"): [ 91 | "NewDNSServers", 92 | "NewExternalIPAddress", 93 | "NewMACAddress", 94 | "NewUserName", 95 | ], 96 | ("WANPPPConnection1", "GetUserName"): ["NewUserName"], 97 | ("WANPPPConnection1", "X_GetDNSServers"): ["NewDNSServers"], 98 | ("WLANConfiguration1", "GetBSSID"): ["NewBSSID"], 99 | ("WLANConfiguration1", "GetInfo"): ["NewBSSID", "NewSSID"], 100 | ("WLANConfiguration1", "GetSSID"): ["NewSSID"], 101 | ("WLANConfiguration1", "GetSecurityKeys"): [ 102 | "NewKeyPassphrase", 103 | "NewPreSharedKey", 104 | "NewWEPKey0", 105 | "NewWEPKey1", 106 | "NewWEPKey2", 107 | "NewWEPKey3", 108 | ], 109 | ("WLANConfiguration1", "X_AVM-DE_GetWLANDeviceListPath"): [ 110 | "NewX_AVM-DE_WLANDeviceListPath" 111 | ], 112 | ("WLANConfiguration1", "X_AVM-DE_GetWLANHybridMode"): ["NewBSSID", "NewSSID"], 113 | ("WLANConfiguration2", "GetBSSID"): ["NewBSSID"], 114 | ("WLANConfiguration2", "GetInfo"): ["NewBSSID", "NewSSID"], 115 | ("WLANConfiguration2", "GetSSID"): ["NewSSID"], 116 | ("WLANConfiguration2", "GetSecurityKeys"): [ 117 | "NewKeyPassphrase", 118 | "NewPreSharedKey", 119 | "NewWEPKey0", 120 | "NewWEPKey1", 121 | "NewWEPKey2", 122 | "NewWEPKey3", 123 | ], 124 | ("WLANConfiguration2", "X_AVM-DE_GetWLANDeviceListPath"): [ 125 | "NewX_AVM-DE_WLANDeviceListPath" 126 | ], 127 | ("WLANConfiguration2", "X_AVM-DE_GetWLANHybridMode"): ["NewBSSID", "NewSSID"], 128 | ("WLANConfiguration3", "GetBSSID"): ["NewBSSID"], 129 | ("WLANConfiguration3", "GetInfo"): ["NewBSSID", "NewSSID"], 130 | ("WLANConfiguration3", "GetSSID"): ["NewSSID"], 131 | ("WLANConfiguration3", "GetSecurityKeys"): [ 132 | "NewKeyPassphrase", 133 | "NewPreSharedKey", 134 | "NewWEPKey0", 135 | "NewWEPKey1", 136 | "NewWEPKey2", 137 | "NewWEPKey3", 138 | ], 139 | ("WLANConfiguration3", "X_AVM-DE_GetWLANDeviceListPath"): [ 140 | "NewX_AVM-DE_WLANDeviceListPath" 141 | ], 142 | ("WLANConfiguration3", "X_AVM-DE_GetWLANHybridMode"): ["NewBSSID", "NewSSID"], 143 | ("WLANConfiguration4", "GetBSSID"): ["NewBSSID"], 144 | ("WLANConfiguration4", "GetInfo"): ["NewBSSID", "NewSSID"], 145 | ("WLANConfiguration4", "GetSSID"): ["NewSSID"], 146 | ("WLANConfiguration4", "GetSecurityKeys"): [ 147 | "NewKeyPassphrase", 148 | "NewPreSharedKey", 149 | "NewWEPKey0", 150 | "NewWEPKey1", 151 | "NewWEPKey2", 152 | "NewWEPKey3", 153 | ], 154 | ("WLANConfiguration4", "X_AVM-DE_GetWLANDeviceListPath"): [ 155 | "NewX_AVM-DE_WLANDeviceListPath" 156 | ], 157 | ("WLANConfiguration4", "X_AVM-DE_GetWLANHybridMode"): ["NewBSSID", "NewSSID"], 158 | ("X_AVM-DE_AppSetup1", "GetAppRemoteInfo"): [ 159 | "NewExternalIPAddress", 160 | "NewExternalIPv6Address", 161 | "NewIPAddress", 162 | "NewMyFritzDynDNSName", 163 | "NewRemoteAccessDDNSDomain", 164 | ], 165 | ("X_AVM-DE_Dect1", "GetDectListPath"): ["NewDectListPath"], 166 | ("X_AVM-DE_Filelinks1", "GetFilelinkListPath"): ["NewFilelinkListPath"], 167 | ("X_AVM-DE_MyFritz1", "GetInfo"): ["NewDynDNSName", "NewPort"], 168 | ("X_AVM-DE_OnTel1", "GetCallBarringList"): ["NewPhonebookURL"], 169 | ("X_AVM-DE_OnTel1", "GetCallList"): ["NewCallListURL"], 170 | ("X_AVM-DE_OnTel1", "GetDECTHandsetList"): ["NewDectIDList"], 171 | ("X_AVM-DE_OnTel1", "GetDeflections"): ["NewDeflectionList"], 172 | ("X_AVM-DE_RemoteAccess1", "GetDDNSInfo"): ["NewDomain", "NewUpdateURL", "NewUsername"], 173 | ("X_AVM-DE_RemoteAccess1", "GetInfo"): ["NewPort", "NewUsername"], 174 | ("X_AVM-DE_Storage1", "GetUserInfo"): ["NewUsername"], 175 | ("X_AVM-DE_TAM1", "GetList"): ["NewTAMList"], 176 | ("X_VoIP1", "X_AVM-DE_GetClients"): ["NewX_AVM-DE_ClientList"], 177 | ("X_VoIP1", "X_AVM-DE_GetNumbers"): ["NewNumberList"], 178 | } 179 | 180 | for svc_action in res: 181 | if svc_action in blacklist: 182 | for field in blacklist[svc_action]: 183 | if field in res[svc_action]: 184 | res[svc_action][field] = "" 185 | 186 | for entry in sanitation: 187 | if (entry[0], entry[1]) in res: 188 | if len(entry) == 2: # noqa: PLR2004 189 | for field in res[(entry[0], entry[1])]: 190 | res[(entry[0], entry[1])][field] = "" 191 | elif len(entry) == 3 and entry[2] in res[(entry[0], entry[1])]: # noqa: PLR2004 192 | res[(entry[0], entry[1])][entry[2]] = "" 193 | 194 | return res 195 | 196 | 197 | def jsonify_action_results(ar: dict[tuple[str, str], dict]) -> dict[str, dict]: 198 | out: dict[str, dict] = {} 199 | for service, action in ar: 200 | if service not in out: 201 | out[service] = {} 202 | if action not in out[service]: 203 | out[service][action] = {k: str(v) for k, v in ar[(service, action)].items()} 204 | return out 205 | 206 | 207 | def upload_data(basedata) -> None: # noqa: ANN001 208 | donation_url = os.getenv("FRITZ_DONATION_URL", "https://fritz.dreker.de/data/donate") 209 | headers = {"Content-Type": "application/json"} 210 | resp = requests.post(donation_url, data=json.dumps(basedata), headers=headers, timeout=10) 211 | 212 | if resp.status_code == requests.codes.ok: 213 | donation_id = resp.json().get("donation_id") 214 | if donation_id: 215 | logger.info( 216 | "Data donation for device %s registered under id %s", 217 | basedata["fritzdevice"]["model"], 218 | donation_id, 219 | ) 220 | else: 221 | logger.warning( 222 | "Data donation for device %s did not return a donation id.", 223 | basedata["fritzdevice"]["model"], 224 | ) 225 | else: 226 | resp.raise_for_status() 227 | 228 | 229 | def donate_data( 230 | device: FritzDevice, *, upload: bool = False, sanitation: list[list] | None = None 231 | ) -> None: 232 | if not sanitation: 233 | sanitation = [] 234 | services = {s: list(device.fc.services[s].actions) for s in device.fc.services} 235 | model = device.model 236 | sw_version = get_sw_version(device) 237 | 238 | detected_capabilities = list(device.capabilities.capabilities) 239 | 240 | action_results = {} 241 | for service, actions in services.items(): 242 | for action in actions: 243 | if "Get" in action and not ( 244 | "ByIP" in action 245 | or "ByIndex" in action 246 | or "GetSpecific" in action 247 | or "GetGeneric" in action 248 | ): 249 | res = safe_call_action(device, service, action) 250 | action_results[(service, action)] = res 251 | 252 | basedata = { 253 | "exporter_version": __version__, 254 | "fritzdevice": { 255 | "model": model, 256 | "os_version": sw_version, 257 | "services": services, 258 | "detected_capabilities": detected_capabilities, 259 | "action_results": jsonify_action_results(sanitize_results(action_results, sanitation)), 260 | }, 261 | } 262 | 263 | if upload: 264 | upload_data(basedata) 265 | else: 266 | print(f"---------------- Donation data for device {model} ---------------------") # noqa: T201 267 | print(json.dumps(basedata, indent=2)) # noqa: T201 268 | print("----------------- END ------------------") # noqa: T201 269 | print() # noqa: T201 270 | -------------------------------------------------------------------------------- /tests/test_fritzdevice.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from pprint import pprint 3 | from unittest.mock import MagicMock, call, patch 4 | 5 | import pytest 6 | from fritzconnection.core.exceptions import FritzConnectionException, FritzServiceError 7 | from prometheus_client.core import Metric 8 | 9 | from fritzexporter.exceptions import FritzDeviceHasNoCapabilitiesError 10 | from fritzexporter.fritzdevice import FritzCollector, FritzCredentials, FritzDevice 11 | from fritzexporter.fritz_aha import parse_aha_device_xml 12 | 13 | from .fc_services_mock import ( 14 | call_action_mock, 15 | call_action_no_basic_mock, 16 | call_http_mock, 17 | create_fc_services, 18 | fc_services_capabilities, 19 | fc_services_devices, 20 | fc_services_no_basic_info, 21 | ) 22 | 23 | FRITZDEVICE_LOG_SOURCE = "fritzexporter.fritzdevice" 24 | 25 | 26 | @patch("fritzexporter.fritzdevice.FritzConnection") 27 | class TestFritzDevice: 28 | @pytest.mark.parametrize("capability", fc_services_capabilities.keys()) 29 | def test_should_create_a_device_with_presented_capability( 30 | self, mock_fritzconnection, capability, caplog 31 | ): 32 | # Prepare 33 | caplog.set_level(logging.DEBUG) 34 | 35 | fc = mock_fritzconnection.return_value 36 | fc.call_action.side_effect = call_action_mock 37 | fc.services = create_fc_services(fc_services_capabilities[capability]) 38 | 39 | # Act 40 | if capability == "HostInfo": 41 | fd = FritzDevice(FritzCredentials("somehost", "someuser", "password"), "FritzMock", host_info=True) 42 | else: 43 | fd = FritzDevice(FritzCredentials("somehost", "someuser", "password"), "FritzMock", host_info=False) 44 | 45 | # Check 46 | print(caplog.text) 47 | assert fd.model == "Fritz!MockBox 9790" 48 | assert fd.serial == "1234567890" 49 | LOGSOURCE = "fritzexporter.fritzcapability" 50 | if capability == "WlanConfigurationInfo": 51 | assert ( 52 | LOGSOURCE, 53 | logging.DEBUG, 54 | f"Capability {capability} in WLAN 1 set to True on device somehost", 55 | ) in caplog.record_tuples 56 | assert ( 57 | LOGSOURCE, 58 | logging.DEBUG, 59 | f"Capability {capability} in WLAN 2 set to True on device somehost", 60 | ) in caplog.record_tuples 61 | assert ( 62 | LOGSOURCE, 63 | logging.DEBUG, 64 | f"Capability {capability} in WLAN 3 set to False on device somehost", 65 | ) in caplog.record_tuples 66 | assert ( 67 | LOGSOURCE, 68 | logging.DEBUG, 69 | f"Capability {capability} in WLAN 4 set to False on device somehost", 70 | ) in caplog.record_tuples 71 | else: 72 | assert ( 73 | LOGSOURCE, 74 | logging.DEBUG, 75 | f"Capability {capability} set to True on device somehost", 76 | ) in caplog.record_tuples 77 | 78 | def test_should_raise_fritz_connection_exception(self, mock_fritzconnection: MagicMock, caplog): 79 | # Prepare 80 | caplog.set_level(logging.DEBUG) 81 | 82 | mock_fritzconnection.side_effect = FritzConnectionException("somehost: connection refused") 83 | 84 | # Act 85 | with pytest.raises(FritzConnectionException): 86 | _ = FritzDevice(FritzCredentials("somehost", "someuser", "password"), "FritzMock", host_info=False) 87 | 88 | # Check 89 | assert ( 90 | FRITZDEVICE_LOG_SOURCE, 91 | logging.ERROR, 92 | "unable to connect to somehost.", 93 | ) in caplog.record_tuples 94 | 95 | def test_should_invalidate_presented_service(self, mock_fritzconnection: MagicMock, caplog): 96 | # Prepare 97 | caplog.set_level(logging.DEBUG) 98 | 99 | fc = mock_fritzconnection.return_value 100 | fc.call_action.side_effect = FritzServiceError("Mock FritzServiceError") 101 | fc.services = create_fc_services(fc_services_devices["FritzBox 7590"]) 102 | 103 | # Act 104 | with pytest.raises(FritzDeviceHasNoCapabilitiesError): 105 | _ = FritzDevice(FritzCredentials("somehost", "someuser", "password"), "FritzMock", host_info=True) 106 | 107 | def test_should_create_fritz_device_with_correct_capabilities( 108 | self, mock_fritzconnection: MagicMock, caplog 109 | ): 110 | # Prepare 111 | caplog.set_level(logging.DEBUG) 112 | 113 | fc = mock_fritzconnection.return_value 114 | fc.call_action.side_effect = call_action_mock 115 | fc.services = create_fc_services(fc_services_devices["FritzBox 7590"]) 116 | 117 | # Act 118 | fd = FritzDevice(FritzCredentials("somehost", "someuser", "password"), "FritzMock", host_info=False) 119 | 120 | # Check 121 | assert fd.model == "Fritz!MockBox 9790" 122 | assert fd.serial == "1234567890" 123 | 124 | assert mock_fritzconnection.call_count == 1 125 | assert mock_fritzconnection.call_args == call( 126 | address="somehost", user="someuser", password="password" 127 | ) 128 | 129 | def test_should_complain_about_password(self, mock_fritzconnection: MagicMock, caplog): 130 | # Prepare 131 | caplog.set_level(logging.DEBUG) 132 | password: str = "123456789012345678901234567890123" 133 | 134 | fc = mock_fritzconnection.return_value 135 | fc.call_action.side_effect = call_action_mock 136 | fc.services = create_fc_services(fc_services_devices["FritzBox 7590"]) 137 | 138 | # Act 139 | _ = FritzDevice(FritzCredentials("somehost", "someuser", password), "Fritz!Mock", host_info=False) 140 | 141 | # Check 142 | assert ( 143 | FRITZDEVICE_LOG_SOURCE, 144 | logging.WARN, 145 | "Password is longer than 32 characters! Login may not succeed, please see README!", 146 | ) in caplog.record_tuples 147 | 148 | def test_should_find_no_capabilities(self, mock_fritzconnection: MagicMock, caplog): 149 | # Prepare 150 | caplog.set_level(logging.DEBUG) 151 | password: str = "123456789012345678901234567890123" 152 | 153 | fc = mock_fritzconnection.return_value 154 | fc.call_action.side_effect = call_action_mock 155 | fc.services = create_fc_services({}) 156 | 157 | # Act 158 | with pytest.raises(FritzDeviceHasNoCapabilitiesError): 159 | _ = FritzDevice(FritzCredentials("somehost", "someuser", password), "FritzMock", host_info=False) 160 | 161 | # Check 162 | assert ( 163 | FRITZDEVICE_LOG_SOURCE, 164 | logging.CRITICAL, 165 | "Device somehost has no detected capabilities. Exiting.", 166 | ) in caplog.record_tuples 167 | 168 | def test_should_detect_no_basic_info(self, mock_fritzconnection: MagicMock, caplog): 169 | # Prepare 170 | caplog.set_level(logging.DEBUG) 171 | 172 | fc = mock_fritzconnection.return_value 173 | fc.call_action.side_effect = call_action_no_basic_mock 174 | fc.services = create_fc_services(fc_services_no_basic_info) 175 | 176 | # Act 177 | _ = FritzDevice(FritzCredentials("somehost", "someuser", "password"), "FritzMock", host_info=False) 178 | 179 | # Check 180 | assert ( 181 | FRITZDEVICE_LOG_SOURCE, 182 | logging.ERROR, 183 | "Fritz Device somehost does not provide basic device " 184 | "info (Service: DeviceInfo1, Action: GetInfo)." 185 | "Serial number and model name will be unavailable.", 186 | ) in caplog.record_tuples 187 | 188 | def test_should_correctly_parse_aha_xml(self, mock_fritzconnection: MagicMock, caplog): 189 | # Prepare 190 | deviceinfo = """ 191 | 192 | 1 193 | Fritz!DECT 200 194 | 195 | AVM 196 | http://www.avm.de 197 | Fritz!DECT 200 198 | 100 199 | 0 200 | 201 | """ 202 | # Act 203 | device_data = parse_aha_device_xml(deviceinfo) 204 | 205 | # Check 206 | assert device_data["battery_level"] == "100" 207 | assert device_data["battery_low"] == "0" 208 | 209 | def test_should_correctly_parse_aha_xml_when_empty(self, mock_fritzconnection: MagicMock, caplog): 210 | # Prepare 211 | deviceinfo = """ 212 | 213 | 214 | """ 215 | # Act 216 | device_data = parse_aha_device_xml(deviceinfo) 217 | 218 | # Check 219 | assert "battery_level" not in device_data 220 | assert "battery_low" not in device_data 221 | 222 | 223 | 224 | @patch("fritzexporter.fritzdevice.FritzConnection") 225 | class TestFritzCollector: 226 | def test_should_instantiate_empty_collector(self, caplog): 227 | # Prepare 228 | caplog.set_level(logging.DEBUG) 229 | 230 | # Act 231 | collector = FritzCollector() 232 | 233 | # Check 234 | assert collector.devices == [] 235 | 236 | all_capas = [ 237 | "DeviceInfo", 238 | "HostNumberOfEntries", 239 | "UserInterface", 240 | "LanInterfaceConfig", 241 | "LanInterfaceConfigStatistics", 242 | "WanDSLInterfaceConfig", 243 | "WanDSLInterfaceConfigAVM", 244 | "WanPPPConnectionStatus", 245 | "WanCommonInterfaceConfig", 246 | "WanCommonInterfaceDataBytes", 247 | "WanCommonInterfaceByteRate", 248 | "WanCommonInterfaceDataPackets", 249 | "WlanConfigurationInfo", 250 | "HostInfo", 251 | "HomeAutomation", 252 | ] 253 | 254 | assert list(collector.capabilities.capabilities.keys()) == all_capas 255 | 256 | def test_should_register_device_to_collector(self, mock_fritzconnection: MagicMock, caplog): 257 | # Prepare 258 | caplog.set_level(logging.DEBUG) 259 | 260 | fc = mock_fritzconnection.return_value 261 | fc.call_action.side_effect = call_action_mock 262 | fc.services = create_fc_services(fc_services_devices["FritzBox 7590"]) 263 | 264 | # Act 265 | collector = FritzCollector() 266 | device = FritzDevice(FritzCredentials("somehost", "someuser", "password"), "FritzMock", host_info=False) 267 | collector.register(device) 268 | 269 | # Check 270 | assert len(collector.devices) == 1 271 | assert device is collector.devices[0] 272 | 273 | def test_should_collect_metrics_from_device(self, mock_fritzconnection: MagicMock, caplog): 274 | # Prepare 275 | caplog.set_level(logging.DEBUG) 276 | 277 | fc = mock_fritzconnection.return_value 278 | fc.call_action.side_effect = call_action_mock 279 | fc.call_http.side_effect = call_http_mock 280 | fc.services = create_fc_services(fc_services_devices["FritzBox 7590"]) 281 | 282 | # Act 283 | collector = FritzCollector() 284 | device = FritzDevice(FritzCredentials("somehost", "someuser", "password"), "FritzMock", host_info=False) 285 | collector.register(device) 286 | metrics: list[Metric] = list(collector.collect()) 287 | 288 | # Check 289 | assert len(collector.devices) == 1 290 | assert device is collector.devices[0] 291 | for m in metrics: 292 | for s in m.samples: 293 | assert "serial" in s.labels 294 | assert s.labels["serial"] == "1234567890" 295 | assert "friendly_name" in s.labels 296 | assert s.labels["friendly_name"] == "FritzMock" 297 | 298 | def test_should_collect_host_info_from_device(self, mock_fritzconnection: MagicMock, caplog): 299 | # Prepare 300 | caplog.set_level(logging.DEBUG) 301 | 302 | fc = mock_fritzconnection.return_value 303 | fc.call_action.side_effect = call_action_mock 304 | fc.services = create_fc_services(fc_services_devices["FritzBox 7590"]) 305 | 306 | # Act 307 | collector = FritzCollector() 308 | device = FritzDevice(FritzCredentials("somehost", "someuser", "password"), "FritzMock", host_info=True) 309 | collector.register(device) 310 | metrics: list[Metric] = list(collector.collect()) 311 | 312 | # Check 313 | assert len(collector.devices) == 1 314 | assert device is collector.devices[0] 315 | pprint(metrics) 316 | 317 | prom_metrics = [m.name for m in metrics] 318 | assert "fritz_host_speed" in prom_metrics 319 | assert "fritz_host_active" in prom_metrics 320 | 321 | def test_should_only_expose_one_metric_for_multiple_devices( 322 | self, mock_fritzconnection: MagicMock, caplog 323 | ): 324 | # Prepare 325 | caplog.set_level(logging.DEBUG) 326 | 327 | fc = mock_fritzconnection.return_value 328 | fc.call_action.side_effect = call_action_mock 329 | fc.services = create_fc_services(fc_services_devices["FritzBox 7590"]) 330 | 331 | # Act 332 | collector = FritzCollector() 333 | device1 = FritzDevice(FritzCredentials("somehost", "someuser", "password"), "FritzMock1", host_info=True) 334 | device2 = FritzDevice(FritzCredentials("somehost", "someuser", "password"), "FritzMock2", host_info=True) 335 | device3 = FritzDevice(FritzCredentials("somehost", "someuser", "password"), "FritzMock3", host_info=True) 336 | collector.register(device1) 337 | collector.register(device2) 338 | collector.register(device3) 339 | 340 | # TODO: Might be worth to check this for every metric? 341 | metrics: list[Metric] = [] 342 | for m in collector.collect(): 343 | if m.name == "fritz_uptime_seconds": 344 | metrics.append(m) 345 | 346 | # Check 347 | assert len(metrics) == 1 348 | assert len(metrics[0].samples) == 3 349 | --------------------------------------------------------------------------------