From ab3feeec4a90eb4d8b59852072d6e17419cddde4 Mon Sep 17 00:00:00 2001 From: Stephane Bortzmeyer Date: Mon, 5 Jul 2021 20:49:49 +0200 Subject: [PATCH] First version --- README.md | 31 ++++++++- TODO | 3 + check_expire | 150 +++++++++++++++++++++++++++++++++++++++++++ ianardap.py | 68 ++++++++++++++++++++ tests.yaml | 175 +++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 426 insertions(+), 1 deletion(-) create mode 100644 TODO create mode 100755 check_expire create mode 100644 ianardap.py create mode 100644 tests.yaml diff --git a/README.md b/README.md index 20b1c8d..4f0cba7 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,32 @@ # check_expire -A Nagios (and compatible) plugin to check the impending expiration of a domain name. It relies on RDAP exclusively (no whois) and works with every top-level domain with RDAP (which includes all the ICANN ones). \ No newline at end of file +A Nagios (and compatible) plugin to check the impending expiration of a domain name. It relies on RDAP exclusively (no whois) and works with every top-level domain with RDAP (which includes all the ICANN ones). + +## Usage + +check_expire follows the usual Nagios rules. The options are: + +* -H: domain name to monitor +* -c: critical threshold in days +* -w: warning threshold in days +* -v: verbose details in output + +## Installation + +You need Python 3 and [Requests](http://python-requests.org/). You can install Requests, for instance, with pip `pip3 install requests`. + +Then, copy the script `check_expire` and the file `ianardap.py`to the directory of local plugins. + +## License + +GPL. See LICENSE. + +## Authors + +Stéphane Bortzmeyer . + +## Reference site + +https://forge.chapril.org/bortzmeyer/check_expire Use the Gitea issue tracker to report bugs or wishes. + + diff --git a/TODO b/TODO new file mode 100644 index 0000000..bd515c3 --- /dev/null +++ b/TODO @@ -0,0 +1,3 @@ +Icinga template + + diff --git a/check_expire b/check_expire new file mode 100755 index 0000000..a60fba0 --- /dev/null +++ b/check_expire @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 + +"""Monitoring plugin (Nagios-compatible) to check the impeding +expiration of a domain name, through RDAP. + +The monitoring plugin API is documented at +. + +""" + +import sys +import json +import datetime +import getopt +import re + +# http://python-requests.org/ for easier HTTPS retrieval +import requests + +# Local module +import ianardap + +VERSION = "0.0" + +# Funny diversity in RDAP servers' output... +RFC3339 = "%Y-%m-%dT%H:%M:%SZ%z" +RFC3339TZ = "%Y-%m-%dT%H:%M:%S%z" # We cannot use it directly, Python uses HHMM as offset, not HH:MM as RFC 3339 does :-( +RFC3339LONG = "%Y-%m-%dT%H:%M:%S.%fZ%z" # Microseconds support (%f) does not seem documented + +UTCINFO = datetime.timezone(offset=datetime.timedelta(seconds=0)) + +# Do not touch +# https://www.monitoring-plugins.org/doc/guidelines.html#AEN78 +STATE_OK = 0 +STATE_WARNING = 1 +STATE_CRITICAL = 2 +STATE_UNKNOWN = 3 +STATE_DEPENDENT = 4 + +# Can be changed on the command-line +verbose = False +domain = None +critical_t = datetime.timedelta(days=2) +warning_t = datetime.timedelta(days=7) +# TODO implement timeout for HTTPS requests. -t option. Does Requests allow it? + +def usage(msg=None): + print("Usage: %s -H domain-name [-c critical -w warning]" % sys.argv[0], end="") + if msg is not None and msg != "": + print(" (%s)" % msg) + else: + print("") + +def details(): + if verbose: + print(" RDAP database \"%s\", version %s published on %s, RDAP server is %s" % \ + (database.description, database.version, database.publication, server)) + else: + print("") + +def error(msg=None): + if msg is None: + msg = "Unknown error" + print("%s CRITICAL: %s" % (domain, msg), end="") + details() + sys.exit(STATE_CRITICAL) + +def warning(msg=None): + if msg is None: + msg = "Unknown warning" + print("%s WARNING: %s" % (domain, msg), end="") + details() + sys.exit(STATE_WARNING) + +def ok(msg=None): + if msg is None: + msg = "Unknown message but everything is OK" + print("%s OK: %s" % (domain, msg), end="") + details() + sys.exit(STATE_OK) + +try: + optlist, args = getopt.getopt (sys.argv[1:], "c:hH:vVw:", + ["critical=", "expiration", "help", "verbose", "version", "warning="]) + for option, value in optlist: + if option == "--critical" or option == "-c": + critical_t = datetime.timedelta(days=int(value)) + elif option == "--help" or option == "-h": + usage() + sys.exit(STATE_OK) + elif option == "--hostname" or option == "-H": + domain = value + elif option == "--verbose" or option == "-v": + verbose = True + elif option == "--version" or option == "-V": + print("%s version %s" % (sys.argv[0], VERSION)) + sys.exit(STATE_OK) + elif option == "--warning" or option == "-w": + warning_t = datetime.timedelta(days=int(value)) + else: + # Should never occur, it is trapped by getopt + print("Unknown option %s" % option) + sys.exit(STATE_UNKNOWN) +except getopt.error as reason: + usage(reason) + sys.exit(STATE_UNKNOWN) +if len(args) != 0: + usage("Extraneous arguments") + sys.exit(STATE_UNKNOWN) +if domain is None: + usage("-H is mandatory") + sys.exit(STATE_UNKNOWN) +database = ianardap.IanaRDAPDatabase() +server = database.find(domain) +if server is None: + error("No RDAP server found for %s" % domain) +if server.endswith("/"): + server = server[:-1] # Donuts RDAP server balks when there are two slashes and reply 404 +response = requests.get("%s/domain/%s" % (server, domain)) +if response.status_code != 200: + error("Invalid RDAP return code: %s" % response.status_code) +rdap = json.loads(response.content) +for event in rdap["events"]: + if event["eventAction"] == "expiration": + try: + expiration = datetime.datetime.strptime(event["eventDate"] + "+0000", RFC3339LONG) + except ValueError: + try: + expiration = datetime.datetime.strptime(event["eventDate"] + "+0000", RFC3339) + except ValueError: + # Python standard library offers no way to parse all RFC3339-compliant datetimes, so we have to hack. + if re.search(r"\+[0-9]{2}:[0-9]{2}$", event["eventDate"]): + dt = re.sub(r"\+([0-9]{2}):([0-9]{2})$", r"+\1\2", event["eventDate"]) + expiration = datetime.datetime.strptime(dt, RFC3339TZ) + else: + print("No recognized format for datetime \"%s\"" % event["eventDate"]) + sys.exit(STATE_UNKNOWN) + now = datetime.datetime.now(tz=UTCINFO) + if expiration < now: + error("domain %s is already expired (%s ago)" % (domain, (now-expiration))) + rest = expiration-now + if rest < critical_t: + error("expires in %s, HURRY UP!!!" % (rest)) + elif rest < warning_t: + warning("expires in %s, renew now" % (rest)) + else: + ok("expires in %s." % (rest)) +error("No expiration found") + + diff --git a/ianardap.py b/ianardap.py new file mode 100644 index 0000000..a718bc5 --- /dev/null +++ b/ianardap.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 + +"""A simple module to get the RDAP server for a given domain name, +from the IANA database specified in RFC 7484. + +""" + +# http://python-requests.org/ for easier HTTPS retrieval +import requests + +import datetime +import json +import os +import sys + +IANABASE = "https://data.iana.org/rdap/dns.json" +CACHE = os.environ["HOME"] + "/.ianardapcache.json" +MAXAGE = 24 # Hours + +class IanaRDAPDatabase(): + + def __init__(self, maxage=MAXAGE, cachefile=CACHE): + """ Retrieves the IANA databse, if not already cached. maxage is in hours. """ + cache_valid = False + # TODO we should lock it + if os.path.exists(cachefile) and \ + datetime.datetime.fromtimestamp(os.path.getmtime(cachefile)) >= \ + (datetime.datetime.utcnow() - datetime.timedelta(hours = maxage)): + cache = open(cachefile, "rb") + content = cache.read() + cache.close() + cache_valid = True + else: + response = requests.get(IANABASE) + if response.status_code != 200: + raise Exception("Invalid HTTPS return code when trying to get %s: %s" % (IANABASE, response.status_code)) + content = response.content + database = json.loads(content) + self.description = database["description"] + self.publication = database["publication"] + self.version = database["version"] + self.services = {} + for service in database["services"]: + for tld in service[0]: + for server in service[1]: + self.services[tld] = server + if not cache_valid: + # TODO we should lock it + cache = open(cachefile, "wb") + cache.write(content) + cache.close() + + def find(self, domain): + """ Get the RDAP server for a given domain name. None if there is none.""" + labels = domain.split(".") + tld = labels[len(labels)-1] + if tld in self.services: + return self.services[tld] + else: + return None + +if __name__ == "__main__": + rdap = IanaRDAPDatabase(maxage=1) + print("Database \"%s\", version %s published on %s, %i services" % \ + (rdap.description, rdap.version, rdap.publication, len(rdap.services))) + for domain in sys.argv[1:]: + print("%s -> %s" % (domain, rdap.find(domain))) + diff --git a/tests.yaml b/tests.yaml new file mode 100644 index 0000000..f5581e3 --- /dev/null +++ b/tests.yaml @@ -0,0 +1,175 @@ +# Run these tests from test_exe_matrix (reference Web site is +# ). You can install it +# with 'pip3 install test_exe_matrix'. + +--- +config: + timeout: 20 + +tests: + - exe: './check_expire' + args: + - '-h' + retcode: 0 + partstdout: '-H domain-name' + + - exe: './check_expire' + args: + - '--zzz' + retcode: 3 + partstdout: 'option --zzz not recognized' + + - exe: './check_expire' + args: + - '-c' + - 'foobar' + - '-H' + - 'bortzmeyer.org' + retcode: 1 + partstderr: 'ValueError' + + # 2021-07-05: no RDAP server for this TLD + - exe: './check_expire' + args: + - '-H' + - 'bie.re' + retcode: 2 + partstdout: 'No RDAP server' + + # 2021-07-05: no expiration in RDAP for this TLD (but there is one + # in whois) + - exe: './check_expire' + args: + - '-H' + - 'hopkaup.is' + retcode: 2 + partstdout: 'No expiration found' + + - exe: './check_expire' + args: + - '-H' + - 'bortzmeyer.org' + retcode: 0 + stderr: '' + partstdout: 'OK' + + - exe: './check_expire' + args: + - '-v' + - '-H' + - 'bortzmeyer.org' + retcode: 0 + stderr: '' + partstdout: 'RDAP server is' + + - exe: './check_expire' + args: + - '-c' + - '1000' + - '-H' + - 'sources.org' + retcode: 2 + stderr: '' + partstdout: 'HURRY UP' + + - exe: './check_expire' + args: + - '-w' + - '1000' + - '-H' + - 'sources.org' + retcode: 1 + stderr: '' + partstdout: 'renew now' + +# Now, test various RDAP servers + + # Verisign + - exe: './check_expire' + args: + - '-H' + - 'nagios.com' + retcode: 0 + stderr: '' + + # CentralNIC + - exe: './check_expire' + args: + - '-H' + - 'botsin.space' + retcode: 0 + stderr: '' + + # Fred + - exe: './check_expire' + args: + - '-H' + - 'turris.cz' + retcode: 0 + stderr: '' + + # Afnic + - exe: './check_expire' + args: + - '-H' + - 'brest.bzh' + retcode: 0 + stderr: '' + + # Donuts + - exe: './check_expire' + args: + - '-H' + - 'mastodon.social' + retcode: 0 + stderr: '' + + # Iran, expiration date in the past + - exe: './check_expire' + args: + - '-H' + - 'nic.pars' + retcode: 2 + partstdout: "already expired" + stderr: '' + + # Brazil + - exe: './check_expire' + args: + - '-H' + - 'nic.globo' + retcode: 0 + stderr: '' + + # Fury (Canada) + - exe: './check_expire' + args: + - '-H' + - 'blackbox.kiwi' + retcode: 0 + stderr: '' + + # JPRS + - exe: './check_expire' + args: + - '-H' + - 'nic.jprs' + retcode: 0 + stderr: '' + + # Nominet + - exe: './check_expire' + args: + - '-H' + - 'nic.abogado' + retcode: 0 + stderr: '' + + # .at + - exe: './check_expire' + args: + - '-H' + - 'nic.wien' + retcode: 0 + stderr: '' +