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",