diff --git a/data/hooks/diagnosis/12-dnsrecords.py b/data/hooks/diagnosis/12-dnsrecords.py index 53afb2c2d..560127bb0 100644 --- a/data/hooks/diagnosis/12-dnsrecords.py +++ b/data/hooks/diagnosis/12-dnsrecords.py @@ -1,13 +1,21 @@ #!/usr/bin/env python import os +import re -from moulinette.utils.filesystem import read_file +from datetime import datetime, timedelta +from publicsuffix import PublicSuffixList + +from moulinette.utils.process import check_output from yunohost.utils.network import dig from yunohost.diagnosis import Diagnoser from yunohost.domain import domain_list, _build_dns_conf, _get_maindomain +# We put here domains we know has dyndns provider, but that are not yet +# registered in the public suffix list +PENDING_SUFFIX_LIST = ['ynh.fr', 'netlib.re'] + class DNSRecordsDiagnoser(Diagnoser): @@ -17,24 +25,22 @@ class DNSRecordsDiagnoser(Diagnoser): def run(self): - resolvers = read_file("/etc/resolv.dnsmasq.conf").split("\n") - ipv4_resolvers = [r.split(" ")[1] for r in resolvers if r.startswith("nameserver") and ":" not in r] - # FIXME some day ... handle ipv4-only and ipv6-only servers. For now we assume we have at least ipv4 - assert ipv4_resolvers != [], "Uhoh, need at least one IPv4 DNS resolver ..." - - self.resolver = ipv4_resolvers[0] main_domain = _get_maindomain() all_domains = domain_list()["domains"] for domain in all_domains: self.logger_debug("Diagnosing DNS conf for %s" % domain) - is_subdomain = domain.split(".",1)[1] in all_domains + is_subdomain = domain.split(".", 1)[1] in all_domains for report in self.check_domain(domain, domain == main_domain, is_subdomain=is_subdomain): yield report - # FIXME : somewhere, should implement a check for reverse DNS ... - - # FIXME / TODO : somewhere, could also implement a check for domain expiring soon + # Check if a domain buy by the user will expire soon + psl = PublicSuffixList() + domains_from_registrar = [psl.get_public_suffix(domain) for domain in all_domains] + domains_from_registrar = [domain for domain in domains_from_registrar if "." in domain] + domains_from_registrar = set(domains_from_registrar) - set(PENDING_SUFFIX_LIST) + for report in self.check_expiration_date(domains_from_registrar): + yield report def check_domain(self, domain, is_main_domain, is_subdomain): @@ -67,7 +73,6 @@ class DNSRecordsDiagnoser(Diagnoser): results[id_] = "WRONG" discrepancies.append(("diagnosis_dns_discrepancy", r)) - def its_important(): # Every mail DNS records are important for main domain # For other domain, we only report it as a warning for now... @@ -128,7 +133,7 @@ class DNSRecordsDiagnoser(Diagnoser): if r["name"] == "@": current = {part for part in current if not part.startswith("ip4:") and not part.startswith("ip6:")} return expected == current - elif r["type"] == "MX": + elif r["type"] == "MX": # For MX, we want to ignore the priority expected = r["value"].split()[-1] current = r["current"].split()[-1] @@ -136,6 +141,92 @@ class DNSRecordsDiagnoser(Diagnoser): else: return r["current"] == r["value"] + def check_expiration_date(self, domains): + """ + Alert if expiration date of a domain is soon + """ + + details = { + "not_found": [], + "error": [], + "warning": [], + "success": [] + } + + for domain in domains: + expire_date = self.get_domain_expiration(domain) + + if isinstance(expire_date, str): + status_ns, _ = dig(domain, "NS", resolvers="force_external") + status_a, _ = dig(domain, "A", resolvers="force_external") + if "ok" not in [status_ns, status_a]: + details["not_found"].append(( + "diagnosis_domain_%s_details" % (expire_date), + {"domain": domain})) + else: + self.logger_debug("Dyndns domain: %s" % (domain)) + continue + + expire_in = expire_date - datetime.now() + + alert_type = "success" + if expire_in <= timedelta(15): + alert_type = "error" + elif expire_in <= timedelta(45): + alert_type = "warning" + + args = { + "domain": domain, + "days": expire_in.days - 1, + "expire_date": str(expire_date) + } + details[alert_type].append(("diagnosis_domain_expires_in", args)) + + for alert_type in ["success", "error", "warning", "not_found"]: + if details[alert_type]: + if alert_type == "not_found": + meta = {"test": "domain_not_found"} + else: + meta = {"test": "domain_expiration"} + # Allow to ignore specifically a single domain + if len(details[alert_type]) == 1: + meta["domain"] = details[alert_type][0][1]["domain"] + yield dict(meta=meta, + data={}, + status=alert_type.upper() if alert_type != "not_found" else "WARNING", + summary="diagnosis_domain_expiration_" + alert_type, + details=details[alert_type]) + + def get_domain_expiration(self, domain): + """ + Return the expiration datetime of a domain or None + """ + command = "whois -H %s || echo failed" % (domain) + out = check_output(command).strip().split("\n") + + # Reduce output to determine if whois answer is equivalent to NOT FOUND + filtered_out = [line for line in out + if re.search(r'^[a-zA-Z0-9 ]{4,25}:', line, re.IGNORECASE) and + not re.match(r'>>> Last update of whois', line, re.IGNORECASE) and + not re.match(r'^NOTICE:', line, re.IGNORECASE) and + not re.match(r'^%%', line, re.IGNORECASE) and + not re.match(r'"https?:"', line, re.IGNORECASE)] + + # If there is less than 7 lines, it's NOT FOUND response + if len(filtered_out) <= 6: + return "not_found" + + for line in out: + match = re.search(r'Expir.+(\d{4}-\d{2}-\d{2})', line, re.IGNORECASE) + if match is not None: + return datetime.strptime(match.group(1), '%Y-%m-%d') + + match = re.search(r'Expir.+(\d{2}-\w{3}-\d{4})', line, re.IGNORECASE) + if match is not None: + return datetime.strptime(match.group(1), '%d-%b-%Y') + + return "expiration_not_found" + def main(args, env, loggers): return DNSRecordsDiagnoser(args, env, loggers).diagnose() diff --git a/debian/control b/debian/control index 5bcd78491..5061ad4f2 100644 --- a/debian/control +++ b/debian/control @@ -29,7 +29,7 @@ Depends: ${python:Depends}, ${misc:Depends} , redis-server , metronome , git, curl, wget, cron, unzip, jq - , lsb-release, haveged, fake-hwclock, equivs, lsof + , lsb-release, haveged, fake-hwclock, equivs, lsof, whois, python-publicsuffix Recommends: yunohost-admin , ntp, inetutils-ping | iputils-ping , bash-completion, rsyslog diff --git a/locales/en.json b/locales/en.json index 3607052e3..da37f144c 100644 --- a/locales/en.json +++ b/locales/en.json @@ -172,6 +172,13 @@ "diagnosis_dns_missing_record": "According to the recommended DNS configuration, you should add a DNS record with the following info.
Type: {type}
Name: {name}
Value: {value}", "diagnosis_dns_discrepancy": "The following DNS record does not seem to follow the recommended configuration:
Type: {type}
Name: {name}
Current value: {current}
Excepted value: {value}", "diagnosis_dns_point_to_doc": "Please check the documentation at https://yunohost.org/dns_config if you need help about configuring DNS records.", + "diagnosis_domain_expiration_not_found": "Unable to check the expiration date for some domains", + "diagnosis_domain_not_found_details": "The domain {domain} doesn't exist in WHOIS database or is expired!", + "diagnosis_domain_expiration_not_found_details": "The WHOIS information for domain {domain} doesn't seem to contain the information about the expiration date?", + "diagnosis_domain_expiration_success": "Your domains are registered and not going to expire anytime soon.", + "diagnosis_domain_expiration_warning": "Some domains will expire soon!", + "diagnosis_domain_expiration_error": "Some domains will expire VERY SOON!", + "diagnosis_domain_expires_in": "{domain} expires in {days} days.", "diagnosis_services_running": "Service {service} is running!", "diagnosis_services_conf_broken": "Configuration is broken for service {service}!", "diagnosis_services_bad_status": "Service {service} is {status} :(", diff --git a/tests/test_i18n_keys.py b/tests/test_i18n_keys.py index 9125c5d52..874794e11 100644 --- a/tests/test_i18n_keys.py +++ b/tests/test_i18n_keys.py @@ -119,13 +119,17 @@ def find_expected_string_keys(): for level in ["danger", "thirdparty", "warning"]: yield "confirm_app_install_%s" % level + for errortype in ["not_found", "error", "warning", "success", "not_found_details"]: + yield "diagnosis_domain_expiration_%s" % errortype + yield "diagnosis_domain_not_found_details" + for errortype in ["bad_status_code", "connection_error", "timeout"]: yield "diagnosis_http_%s" % errortype yield "password_listed" for i in [1, 2, 3, 4]: yield "password_too_simple_%s" % i - + checks = ["outgoing_port_25_ok", "ehlo_ok", "fcrdns_ok", "blacklist_ok", "queue_ok", "ehlo_bad_answer", "ehlo_unreachable", "ehlo_bad_answer_details",