From 2ef39b9eaa426571bd8553920153c88d11d690cb Mon Sep 17 00:00:00 2001 From: Benjamin Renard Date: Sun, 17 Dec 2023 20:18:58 +0100 Subject: [PATCH] Initial version --- .pre-commit-config.yaml | 37 +++++++ .pylintrc | 17 ++++ README.md | 72 ++++++++++++++ check_syncthing | 211 ++++++++++++++++++++++++++++++++++++++++ setup.cfg | 3 + 5 files changed, 340 insertions(+) create mode 100644 .pre-commit-config.yaml create mode 100644 .pylintrc create mode 100644 README.md create mode 100755 check_syncthing create mode 100644 setup.cfg diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..01fc28a --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,37 @@ +# Pre-commit hooks to run tests and ensure code is cleaned. +# See https://pre-commit.com for more information +repos: +- repo: https://github.com/asottile/pyupgrade + rev: v3.15.0 + hooks: + - id: pyupgrade + args: ['--keep-percent-format', '--py37-plus'] +- repo: https://github.com/psf/black + rev: 23.11.0 + hooks: + - id: black + args: ['--target-version', 'py37', '--line-length', '100'] +- repo: https://github.com/PyCQA/isort + rev: 5.12.0 + hooks: + - id: isort + args: ['--profile', 'black', '--line-length', '100'] +- repo: https://github.com/PyCQA/flake8 + rev: 6.1.0 + hooks: + - id: flake8 + args: ['--max-line-length=100'] +- repo: local + hooks: + - id: pylint + name: pylint + entry: pylint + language: system + types: [python] + require_serial: true +- repo: https://github.com/PyCQA/bandit + rev: 1.7.5 + hooks: + - id: bandit + args: [--skip, "B101", --recursive, "mylib"] +minimum_pre_commit_version: 3.2.0 diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..13f584d --- /dev/null +++ b/.pylintrc @@ -0,0 +1,17 @@ +[MESSAGES CONTROL] +disable=invalid-name, + locally-disabled, + too-many-arguments, + too-many-branches, + too-many-locals, + too-many-return-statements, + too-many-nested-blocks, + too-many-instance-attributes, + too-many-lines, + too-many-statements, + logging-too-many-args, + duplicate-code, + +[FORMAT] +# Maximum number of characters on a single line. +max-line-length=100 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e82877b --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +# Monitoring plugin to check Syncthing status + +This Icinga/Nagios check plugin permit to check Syncthing status : + +- verify Syncthing REST API is responding +- check remote devices last seen datetime +- check system and shared folders errors + +## Installation + +First, generate and retreive the REST API key from Syncthing web interface: + - in Actions menu, click on Configuration + - in General tab, under API key, click on Generate button and copy the key + +``` +API_KEY=n6RseK3HWY5LJ29PzNSUJbmK4XKHa5uV +apt install git +git clone https://gitea.zionetrix.net/bn8/check_syncthing.git /usr/local/src/check_syncthing +mkdir -p /usr/local/lib/nagios/plugins +ln -s /usr/local/src/check_syncthing/check_syncthing /usr/local/lib/nagios/plugins/ +echo "command[check_syncthing]=/usr/local/lib/nagios/plugins/check_syncthing -k $API_KEY" > /etc/nagios/nrpe.d/syncthing.cfg +service nagios-nrpe-server reload +``` + +## Usage + +``` +usage: check_syncthing [-h] [-d] [-v] [-H HOST] [-p PORT] -k API_KEY [-S] + [-t TIMEOUT] [-D DEVICES] [-x EXCLUDED_DEVICES] [-F FOLDERS] + [-X EXCLUDED_FOLDERS] [-w WARNING_DEVICE_LAST_SEEN] + [-c CRITICAL_DEVICE_LAST_SEEN] + +Monitoring plugin to check Syncthing status + +options: + -h, --help show this help message and exit + -d, --debug Enable debug mode (default: False) + -v, --verbose Enable verbose mode (default: False) + -H HOST, --host HOST Syncthing host (default: 127.0.0.1) + -p PORT, --port PORT Syncthing port (default: 8384) + -k API_KEY, --api-key API_KEY + Syncthing REST API key (default: None) + -S, --ssl Enable SSL (default: False) + -t TIMEOUT, --timeout TIMEOUT + Requests timeout (in seconds) (default: 5) + -D DEVICES, --devices DEVICES + Monitor only specified devices (default: []) + -x EXCLUDED_DEVICES, --excluded-devices EXCLUDED_DEVICES + Do not monitor specified devices (default: []) + -F FOLDERS, --folders FOLDERS + Monitor only specified folders (default: []) + -X EXCLUDED_FOLDERS, --excluded-folders EXCLUDED_FOLDERS + Do not monitor specified folders (default: []) + -w WARNING_DEVICE_LAST_SEEN, --warning-device-last-seen WARNING_DEVICE_LAST_SEEN + Warning threshold for device last seen time in hours + (default: 12) + -c CRITICAL_DEVICE_LAST_SEEN, --critical-device-last-seen CRITICAL_DEVICE_LAST_SEEN + Critical threshold for device last seen time in hours + (default: 24) +``` + +## Copyright + +Copyright (c) 2023 Benjamin Renard + +## License + +This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License version 3 as published by the Free Software Foundation. + +This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. diff --git a/check_syncthing b/check_syncthing new file mode 100755 index 0000000..3cad2e9 --- /dev/null +++ b/check_syncthing @@ -0,0 +1,211 @@ +#!/usr/bin/python3 +"""Monitoring plugin to check Syncthing status""" +import argparse +import datetime +import json +import logging +import sys + +import dateutil.parser +import requests + +parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.ArgumentDefaultsHelpFormatter +) + +parser.add_argument("-d", "--debug", action="store_true", help="Enable debug mode") +parser.add_argument("-v", "--verbose", action="store_true", help="Enable verbose mode") + +parser.add_argument("-H", "--host", help="Syncthing host", default="127.0.0.1") +parser.add_argument("-p", "--port", type=int, help="Syncthing port", default=8384) +parser.add_argument("-k", "--api-key", help="Syncthing REST API key", required=True) +parser.add_argument("-S", "--ssl", action="store_true", help="Enable SSL") +parser.add_argument("-t", "--timeout", type=int, help="Requests timeout (in seconds)", default=5) + +parser.add_argument( + "-D", "--devices", action="append", help="Monitor only specified devices", default=[] +) +parser.add_argument( + "-x", "--excluded-devices", action="append", help="Do not monitor specified devices", default=[] +) + +parser.add_argument( + "-F", "--folders", action="append", help="Monitor only specified folders", default=[] +) +parser.add_argument( + "-X", "--excluded-folders", action="append", help="Do not monitor specified folders", default=[] +) + +parser.add_argument( + "-w", + "--warning-device-last-seen", + type=int, + help="Warning threshold for device last seen time in hours", + default=12, +) +parser.add_argument( + "-c", + "--critical-device-last-seen", + type=int, + help="Critical threshold for device last seen time in hours", + default=24, +) + +args = parser.parse_args() + +logformat = f"%(asctime)s - {sys.argv[0]} - %(levelname)s - %(message)s" +if args.debug: + loglevel = logging.DEBUG +elif args.verbose: + loglevel = logging.INFO +else: + loglevel = logging.WARNING + +logging.basicConfig(level=loglevel, format=logformat) + + +def call_api(uri, method=None, data=None, headers=None, what=None): + """Call Syncthing REST API""" + method = method.lower() if method else "get" + headers = headers if headers else {} + headers["X-API-Key"] = args.api_key + try: + r = getattr(requests, method)( + f"http{'s' if args.ssl else ''}://{args.host}:{args.port}/rest/{uri}", + data=json.dumps(data) if data else None, + headers=headers, + timeout=args.timeout, + ) + r.raise_for_status() + except Exception: # pylint: disable=broad-exception-caught + print(f"UNKNOWN - Fail to retreive {what if what else 'data'} from Syncthing REST API") + if data: + print(f"Request data: {json.dumps(data, indent=2)}") + logging.exception("Error on requesting %s %s", method, uri) + sys.exit(3) + try: + return r.json() + except requests.exceptions.JSONDecodeError: + print( + "UNKNOWN - Fail to decode Syncthing REST API data on reqesting " + f"{what if what else 'data'}" + ) + print("Raw API return:") + print(r.text) + sys.exit(3) + + +config = call_api("config", what="configuration") +my_dev_id = config["defaults"]["folder"]["devices"][0]["deviceID"] +logging.debug("Config: %s", json.dumps(config, indent=2)) +devices = { + dev["deviceID"]: dev["name"] + for dev in config["devices"] + if ( + not dev["paused"] + and dev["deviceID"] != my_dev_id + and (not args.devices or dev["name"] in args.devices) + and dev["name"] not in args.excluded_devices + ) +} +logging.debug("Devices from config: %s", json.dumps(devices, indent=2)) + +if args.devices: + unknown_devices = set(args.devices) - set(devices.values()) + if unknown_devices: + print(f"UNKNOWN - Devices(s) are unknowns: {', '.join(unknown_devices)}") + sys.exit(3) + +devices_stats = call_api("stats/device", what="devices stats") +logging.debug("Devices stats: %s", json.dumps(devices_stats, indent=2)) +devices_last_seen = { + dev_id: dateutil.parser.isoparse(info["lastSeen"]) + for dev_id, info in devices_stats.items() + if info["lastConnectionDurationS"] > 0 +} +logging.debug("Device last seen: %s", json.dumps(devices_last_seen, indent=2, default=str)) + +errors = [] +error_level = "OK" +exit_status = { + "OK": 0, + "WARNING": 1, + "CRITICAL": 2, + "UNKNOWN": 3, +} +extra_lines = [] +messages = [] + + +def add_error(level, msg): + """Add error""" + global error_level # pylint: disable=global-statement + error_level = level if exit_status[level] > exit_status[error_level] else error_level + errors.append(msg) + + +warning_device_last_seen = datetime.timedelta(hours=args.warning_device_last_seen) +critical_device_last_seen = datetime.timedelta(hours=args.critical_device_last_seen) +now = datetime.datetime.now().replace(tzinfo=dateutil.tz.tzlocal()) + +extra_lines.append("Remote devices:") +for dev_id, dev_name in devices.items(): + if args.devices and dev_name not in args.devices: + continue + last_seen = devices_last_seen.get(dev_id) + if not last_seen: + add_error("WARNING", f"Never seen device {dev_name}") + extra_lines.append(f"- {dev_name}: Never seen") + continue + delta = now - devices_last_seen.get(dev_id) + delta_str = str(delta).split(".", maxsplit=1)[0] + extra_lines.append(f"- {dev_name}: last seen on {last_seen} ({delta_str})") + if delta < warning_device_last_seen: + continue + error = f"Device {dev_name} not seen since {delta_str}" + if delta >= critical_device_last_seen: + add_error("CRITICAL", error) + elif delta >= warning_device_last_seen: + add_error("WARNING", error) + +extra_lines.append("Folders:") +for folder in config["folders"]: + share_with = [ + devices[dev["deviceID"]] for dev in folder["devices"] if dev["deviceID"] in devices + ] + extra_lines.append( + f"- {folder['label']} ({folder['path']}, share with {', '.join(share_with)})" + ) + folder_errors = call_api( + f"folder/errors?folder={folder['id']}", what=f"folder '{folder['label']}' errors" + ) + if folder_errors["errors"]: + add_error( + "WARNING", + f"{len(folder_errors['errors'])} errors detected on folder '{folder['label']}'", + ) + extra_lines.append(f"Folder '{folder['label']}' errors:") + extra_lines.extend( + [f"{error['path']}: {error['error']}" for error in folder_errors["errors"]] + ) + +system_errors = call_api("system/error", what="system errors") +logging.debug("System errors: %s", json.dumps(system_errors, indent=2)) +if system_errors["errors"]: + add_error("WARNING", f"{len(system_errors)} system errors detected") + extra_lines.extend( + [f"{error['when']}: {error['message']}" for error in system_errors["errors"]] + ) + +status = [error_level] +if errors: + status.append(", ".join(errors)) +else: + status.append( + f"All {len(devices)} seen since less than {args.warning_device_last_seen}, " + "no error detected" + ) + +print(" - ".join(status)) +print("\n".join(extra_lines)) +sys.exit(exit_status[error_level]) diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..1899e22 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,3 @@ +[flake8] +ignore = E501,W503 +max-line-length = 100