yunohost/data/hooks/diagnosis/24-mail.py

238 lines
9.6 KiB
Python

#!/usr/bin/env python
import os
import dns.resolver
import socket
import re
from subprocess import CalledProcessError
from moulinette.utils.process import check_output
from moulinette.utils.filesystem import read_yaml
from yunohost.diagnosis import Diagnoser
from yunohost.domain import _get_maindomain, domain_list
from yunohost.settings import settings_get
from yunohost.utils.network import dig
DEFAULT_DNS_BLACKLIST = "/usr/share/yunohost/other/dnsbl_list.yml"
class MailDiagnoser(Diagnoser):
id_ = os.path.splitext(os.path.basename(__file__))[0].split("-")[1]
cache_duration = 600
dependencies = ["ip"]
def run(self):
self.ehlo_domain = _get_maindomain()
self.mail_domains = domain_list()["domains"]
self.ipversions, self.ips = self.get_ips_checked()
# TODO Is a A/AAAA and MX Record ?
# TODO Are outgoing public IPs authorized to send mail by SPF ?
# TODO Validate DKIM and dmarc ?
# TODO check that the recent mail logs are not filled with thousand of email sending (unusual number of mail sent)
# TODO check for unusual failed sending attempt being refused in the logs ?
checks = ["check_outgoing_port_25", "check_ehlo", "check_fcrdns",
"check_blacklist", "check_queue"]
for check in checks:
self.logger_debug("Running " + check)
reports = list(getattr(self, check)())
for report in reports:
yield report
if not reports:
name = check[6:]
yield dict(meta={"test": "mail_" + name},
status="SUCCESS",
summary="diagnosis_mail_" + name + "_ok")
def check_outgoing_port_25(self):
"""
Check outgoing port 25 is open and not blocked by router
This check is ran on IPs we could used to send mail.
"""
for ipversion in self.ipversions:
cmd = '/bin/nc -{ipversion} -z -w2 yunohost.org 25'.format(ipversion=ipversion)
if os.system(cmd) != 0:
yield dict(meta={"test": "outgoing_port_25", "ipversion": ipversion},
data={},
status="ERROR",
summary="diagnosis_mail_outgoing_port_25_blocked",
details=["diagnosis_mail_outgoing_port_25_blocked_details",
"diagnosis_mail_outgoing_port_25_blocked_relay_vpn"])
def check_ehlo(self):
"""
Check the server is reachable from outside and it's the good one
This check is ran on IPs we could used to send mail.
"""
for ipversion in self.ipversions:
try:
r = Diagnoser.remote_diagnosis('check-smtp',
data={},
ipversion=ipversion)
except Exception as e:
yield dict(meta={"test": "mail_ehlo", "reason": "remote_server_failed",
"ipversion": ipversion},
data={"error": str(e)},
status="WARNING",
summary="diagnosis_mail_ehlo_could_not_diagnose",
details=["diagnosis_mail_ehlo_could_not_diagnose_details"])
continue
if r["status"] != "ok":
summary = r["status"].replace("error_smtp_", "diagnosis_mail_ehlo_")
yield dict(meta={"test": "mail_ehlo", "ipversion": ipversion},
data={},
status="ERROR",
summary=summary,
details=[summary + "_details"])
elif r["helo"] != self.ehlo_domain:
yield dict(meta={"test": "mail_ehlo", "ipversion": ipversion},
data={"wrong_ehlo": r["helo"], "right_ehlo": self.ehlo_domain},
status="ERROR",
summary="diagnosis_mail_ehlo_wrong",
details=["diagnosis_mail_ehlo_wrong_details"])
def check_fcrdns(self):
"""
Check the reverse DNS is well defined by doing a Forward-confirmed
reverse DNS check
This check is ran on IPs we could used to send mail.
"""
for ip in self.ips:
if ":" in ip:
ipversion = 6
details = ["diagnosis_mail_fcrdns_nok_details",
"diagnosis_mail_fcrdns_nok_alternatives_6"]
else:
ipversion = 4
details = ["diagnosis_mail_fcrdns_nok_details",
"diagnosis_mail_fcrdns_nok_alternatives_4"]
try:
rdns_domain, _, _ = socket.gethostbyaddr(ip)
except socket.herror:
yield dict(meta={"test": "mail_fcrdns", "ipversion": ipversion},
data={"ip": ip, "ehlo_domain": self.ehlo_domain},
status="ERROR",
summary="diagnosis_mail_fcrdns_dns_missing",
details=details)
continue
if rdns_domain != self.ehlo_domain:
details = ["diagnosis_mail_fcrdns_different_from_ehlo_domain_details"] + details
yield dict(meta={"test": "mail_fcrdns", "ipversion": ipversion},
data={"ip": ip,
"ehlo_domain": self.ehlo_domain,
"rdns_domain": rdns_domain},
status="ERROR",
summary="diagnosis_mail_fcrdns_different_from_ehlo_domain",
details=details)
def check_blacklist(self):
"""
Check with dig onto blacklist DNS server
This check is ran on IPs and domains we could used to send mail.
"""
dns_blacklists = read_yaml(DEFAULT_DNS_BLACKLIST)
for item in self.ips + self.mail_domains:
for blacklist in dns_blacklists:
item_type = "domain"
if ":" in item:
item_type = 'ipv6'
elif re.match(r'^\d+\.\d+\.\d+\.\d+$', item):
item_type = 'ipv4'
if not blacklist[item_type]:
continue
# Build the query for DNSBL
subdomain = item
if item_type != "domain":
rev = dns.reversename.from_address(item)
subdomain = str(rev.split(3)[0])
query = subdomain + '.' + blacklist['dns_server']
# Do the DNS Query
status, _ = dig(query, 'A')
if status != 'ok':
continue
# Try to get the reason
details = []
status, answers = dig(query, 'TXT')
reason = "-"
if status == 'ok':
reason = ', '.join(answers)
details.append("diagnosis_mail_blacklist_reason")
details.append("diagnosis_mail_blacklist_website")
yield dict(meta={"test": "mail_blacklist", "item": item,
"blacklist": blacklist["dns_server"]},
data={'blacklist_name': blacklist['name'],
'blacklist_website': blacklist['website'],
'reason': reason},
status="ERROR",
summary='diagnosis_mail_blacklist_listed_by',
details=details)
def check_queue(self):
"""
Check mail queue is not filled with hundreds of email pending
"""
command = 'postqueue -p | grep -v "Mail queue is empty" | grep -c "^[A-Z0-9]" || true'
try:
output = check_output(command).strip()
pending_emails = int(output)
except (ValueError, CalledProcessError) as e:
yield dict(meta={"test": "mail_queue"},
data={"error": str(e)},
status="ERROR",
summary="diagnosis_mail_queue_unavailable",
details="diagnosis_mail_queue_unavailable_details")
else:
if pending_emails > 100:
yield dict(meta={"test": "mail_queue"},
data={'nb_pending': pending_emails},
status="WARNING",
summary="diagnosis_mail_queue_too_big")
else:
yield dict(meta={"test": "mail_queue"},
data={'nb_pending': pending_emails},
status="SUCCESS",
summary="diagnosis_mail_queue_ok")
def get_ips_checked(self):
outgoing_ipversions = []
outgoing_ips = []
ipv4 = Diagnoser.get_cached_report("ip", {"test": "ipv4"}) or {}
if ipv4.get("status") == "SUCCESS":
outgoing_ipversions.append(4)
global_ipv4 = ipv4.get("data", {}).get("global", {})
if global_ipv4:
outgoing_ips.append(global_ipv4)
if settings_get("smtp.allow_ipv6"):
ipv6 = Diagnoser.get_cached_report("ip", {"test": "ipv6"}) or {}
if ipv6.get("status") == "SUCCESS":
outgoing_ipversions.append(6)
global_ipv6 = ipv6.get("data", {}).get("global", {})
if global_ipv6:
outgoing_ips.append(global_ipv6)
return (outgoing_ipversions, outgoing_ips)
def main(args, env, loggers):
return MailDiagnoser(args, env, loggers).diagnose()