diff --git a/modoboa/admin/app_settings.py b/modoboa/admin/app_settings.py index d0ad679b0..8b343910a 100644 --- a/modoboa/admin/app_settings.py +++ b/modoboa/admin/app_settings.py @@ -51,6 +51,39 @@ class AdminParametersForm(param_forms.AdminParametersForm): ) ) + enable_spf_checks = YesNoField( + label=ugettext_lazy("Enable SPF checks"), + initial=True, + help_text=ugettext_lazy( + "Check if every domain has a valid SPF record" + ) + ) + + enable_dkim_checks = YesNoField( + label=ugettext_lazy("Enable DKIM checks"), + initial=True, + help_text=ugettext_lazy( + "Check if every domain with DKIM signin enabled has a valid DNS " + "record" + ) + ) + + enable_dmarc_checks = YesNoField( + label=ugettext_lazy("Enable DMARC checks"), + initial=True, + help_text=ugettext_lazy( + "Check if every domain has a valid DMARC record" + ) + ) + + enable_autoconfig_checks = YesNoField( + label=ugettext_lazy("Enable autoconfig checks"), + initial=True, + help_text=ugettext_lazy( + "Check if every domain has a valid records for autoconfiguration" + ) + ) + enable_dnsbl_checks = YesNoField( label=ugettext_lazy("Enable DNSBL checks"), initial=True, diff --git a/modoboa/admin/lib.py b/modoboa/admin/lib.py index ea5c54f4f..91c674c15 100644 --- a/modoboa/admin/lib.py +++ b/modoboa/admin/lib.py @@ -23,6 +23,7 @@ from modoboa.core import signals as core_signals from modoboa.core.models import User from modoboa.lib.exceptions import PermDeniedException from modoboa.parameters import tools as param_tools + from . import signals from .models import Alias, Domain, DomainAlias @@ -170,56 +171,67 @@ def import_dlist(user, row, formopts): _import_alias(user, row) -def get_domain_mx_list(domain): - """Return a list of MX IP address for domain.""" - result = [] - logger = logging.getLogger("modoboa.admin") +def get_dns_resolver(): + """Return a DNS resolver object.""" dns_server = param_tools.get_global_parameter("custom_dns_server") if dns_server: resolver = dns.resolver.Resolver() resolver.nameservers = [dns_server] else: resolver = dns.resolver + return resolver + +def get_dns_records(name, typ, resolver=None): + """Retrieve DNS records for given name and type.""" + logger = logging.getLogger("modoboa.admin") + if not resolver: + resolver = get_dns_resolver() try: - dns_answers = resolver.query(domain, "MX") + dns_answers = resolver.query(name, typ) except dns.resolver.NXDOMAIN as e: - logger.error(_("No DNS records found for %s") % domain, exc_info=e) + logger.error(_("No DNS record found for %s") % name, exc_info=e) except dns.resolver.NoAnswer as e: - logger.error(_("No MX record for %s") % domain, exc_info=e) + logger.error(_("No %s record for %s") % (typ, name), exc_info=e) except dns.resolver.NoNameservers as e: logger.error(_("No working name servers found"), exc_info=e) except dns.resolver.Timeout as e: logger.warning( _("DNS resolution timeout, unable to query %s at the moment") % - domain, exc_info=e) + name, exc_info=e) else: - for dns_answer in dns_answers: - for rtype in ["A", "AAAA"]: + return dns_answers + return None + + +def get_domain_mx_list(domain): + """Return a list of MX IP address for domain.""" + result = [] + logger = logging.getLogger("modoboa.admin") + resolver = get_dns_resolver() + dns_answers = get_dns_records(domain, "MX", resolver) + if dns_answers is None: + return result + for dns_answer in dns_answers: + mx_domain = dns_answer.exchange.to_unicode( + omit_final_dot=True, idna_codec=IDNA_2008_UTS_46) + for rtype in ["A", "AAAA"]: + ip_answers = get_dns_records(mx_domain, rtype, resolver) + if not ip_answers: + continue + for ip_answer in ip_answers: try: - mx_domain = dns_answer.exchange.to_unicode( - omit_final_dot=True, idna_codec=IDNA_2008_UTS_46) - ip_answers = resolver.query(mx_domain, rtype) - except dns.resolver.NXDOMAIN as e: - logger.error( - _("No {type} record found for MX {mx}").format( - type=rtype, mx=domain), exc_info=e) - except dns.resolver.NoAnswer: - pass + address_smart = smart_text(ip_answer.address) + mx_ip = ipaddress.ip_address(address_smart) + except ValueError as e: + logger.warning( + _("Invalid IP address format for " + "{domain}; {addr}").format( + domain=mx_domain, + addr=smart_text(ip_answer.address) + ), exc_info=e) else: - for ip_answer in ip_answers: - try: - address_smart = smart_text(ip_answer.address) - mx_ip = ipaddress.ip_address(address_smart) - except ValueError as e: - logger.warning( - _("Invalid IP address format for " - "{domain}; {addr}").format( - domain=mx_domain, - addr=smart_text(ip_answer.address) - ), exc_info=e) - else: - result.append((mx_domain, mx_ip)) + result.append((mx_domain, mx_ip)) return result diff --git a/modoboa/admin/management/commands/subcommands/_mx.py b/modoboa/admin/management/commands/subcommands/_mx.py index 8f0db645e..b88bd30a1 100644 --- a/modoboa/admin/management/commands/subcommands/_mx.py +++ b/modoboa/admin/management/commands/subcommands/_mx.py @@ -19,6 +19,7 @@ from django.utils.functional import cached_property from django.utils.translation import ugettext as _ from modoboa.admin import constants, models +from modoboa.dnstools import models as dns_models from modoboa.parameters import tools as param_tools @@ -193,6 +194,25 @@ class CheckMXRecords(BaseCommand): if param_tools.get_global_parameter("enable_mx_checks"): self.check_valid_mx(domain, mx_list, **options) + if param_tools.get_global_parameter("enable_spf_checks"): + dns_models.DNSRecord.objects.get_or_create_for_domain( + domain, "spf", ttl) + condition = ( + param_tools.get_global_parameter("enable_dkim_checks") and + domain.dkim_public_key + ) + if condition: + dns_models.DNSRecord.objects.get_or_create_for_domain( + domain, "dkim", ttl) + if param_tools.get_global_parameter("enable_dmarc_checks"): + dns_models.DNSRecord.objects.get_or_create_for_domain( + domain, "dmarc", ttl) + if param_tools.get_global_parameter("enable_autoconfig_checks"): + dns_models.DNSRecord.objects.get_or_create_for_domain( + domain, "autoconfig", ttl) + dns_models.DNSRecord.objects.get_or_create_for_domain( + domain, "autodiscover", ttl) + condition = ( not param_tools.get_global_parameter("enable_dnsbl_checks") or options["no_dnsbl"] is True) diff --git a/modoboa/admin/models/domain.py b/modoboa/admin/models/domain.py index 76958ec5c..230a97b81 100644 --- a/modoboa/admin/models/domain.py +++ b/modoboa/admin/models/domain.py @@ -161,6 +161,31 @@ class Domain(AdminObject): else: return "success" + @cached_property + def spf_record(self): + """Return SPF record.""" + return self.dnsrecord_set.filter(type="spf").first() + + @cached_property + def dkim_record(self): + """Return DKIM record.""" + return self.dnsrecord_set.filter(type="dkim").first() + + @cached_property + def dmarc_record(self): + """Return DMARC record.""" + return self.dnsrecord_set.filter(type="dmarc").first() + + @cached_property + def autoconfig_record(self): + """Return autoconfig record.""" + return self.dnsrecord_set.filter(type="autoconfig").first() + + @cached_property + def autodiscover_record(self): + """Return autodiscover record.""" + return self.dnsrecord_set.filter(type="autodiscover").first() + @cached_property def allocated_quota(self): """Return current quota allocation.""" @@ -257,7 +282,7 @@ class Domain(AdminObject): } if self.old_dkim_key_length != self.dkim_key_length: self.dkim_public_key = "" - self.dkim_private_key_path = "" + self.dkim_private_key_path = "" super(Domain, self).save(*args, **kwargs) def delete(self, fromuser, keepdir=False): diff --git a/modoboa/admin/templates/admin/_domain_dnschecks_status.html b/modoboa/admin/templates/admin/_domain_dnschecks_status.html index 1e123a4ae..4fc6fdfec 100644 --- a/modoboa/admin/templates/admin/_domain_dnschecks_status.html +++ b/modoboa/admin/templates/admin/_domain_dnschecks_status.html @@ -11,4 +11,37 @@ {% if enable_dnsbl_checks %} DNSBL {% endif %} + {% if enable_spf_checks %} + {% if not domain.spf_record %} + SPF + {% else %} + + SPF + {% endif %} + {% endif %} + {% if enable_dkim_checks and domain.enable_dkim %} + {% if not domain.dkim_public_key %} + DKIM + {% else %} + + DKIM + {% endif %} + {% endif %} + {% if enable_dmarc_checks %} + {% if not domain.dmarc_record %} + DMARC + {% else %} + + DMARC + {% endif %} + {% endif %} + {% if enable_autoconfig_checks %} + {% if not domain.autoconfig_record and not domain.autodiscover_record %} + autoconfig + {% elif not domain.autoconfig_record or not domain.autodiscover_record %} + autoconfig + {% else %} + autoconfig + {% endif %} + {% endif %} {% endif %} diff --git a/modoboa/admin/tests/test_domain.py b/modoboa/admin/tests/test_domain.py index 81c673dcd..0f54ba663 100644 --- a/modoboa/admin/tests/test_domain.py +++ b/modoboa/admin/tests/test_domain.py @@ -453,4 +453,3 @@ class DKIMTestCase(ModoTestCase): os.unlink(key_path) call_command("modo", "manage_dkim_keys") self.assertTrue(os.path.exists(key_path)) - \ No newline at end of file diff --git a/modoboa/admin/tests/test_mx.py b/modoboa/admin/tests/test_mx.py index 1823be334..65fb88c38 100644 --- a/modoboa/admin/tests/test_mx.py +++ b/modoboa/admin/tests/test_mx.py @@ -110,7 +110,7 @@ class MXTestCase(ModoTestCase): @mock.patch("socket.getaddrinfo") @mock.patch.object(dns.resolver.Resolver, "query") - def test_get_mx_list_dsn_server(self, mock_query, mock_getaddrinfo): + def test_get_mx_list_dns_server(self, mock_query, mock_getaddrinfo): """Test to get mx list from specific DNS server.""" mock_query.side_effect = utils.mock_dns_query_result mock_getaddrinfo.side_effect = utils.mock_ip_query_result @@ -133,7 +133,7 @@ class MXTestCase(ModoTestCase): get_domain_mx_list("bad-response.example.com") log.check( ("modoboa.admin", "ERROR", - _("No DNS records found for %s") + _("No DNS record found for %s") % "does-not-exist.example.com"), ("modoboa.admin", "ERROR", _("No MX record for %s") % "no-mx.example.com"), @@ -142,11 +142,11 @@ class MXTestCase(ModoTestCase): _("DNS resolution timeout, unable to query %s at the moment") % "timeout.example.com"), ("modoboa.admin", "ERROR", - _("No A record found for MX %(domain)s") - % {"domain": "no-lookup.example.com"}), + _("No DNS record found for %s") + % "does-not-exist.example.com"), ("modoboa.admin", "ERROR", - _("No AAAA record found for MX %(domain)s") - % {"domain": "no-lookup.example.com"}), + _("No DNS record found for %s") + % "does-not-exist.example.com"), ("modoboa.admin", "WARNING", _("Invalid IP address format for %(domain)s; %(addr)s") % {"domain": "bad-response.example.com", "addr": "000.0.0.0"}), diff --git a/modoboa/admin/views/domain.py b/modoboa/admin/views/domain.py index 824901cbd..41642d297 100644 --- a/modoboa/admin/views/domain.py +++ b/modoboa/admin/views/domain.py @@ -69,23 +69,25 @@ def _domains(request): } page = get_listing_page(domainlist, request.GET.get("page", 1)) parameters = request.localconfig.parameters + dns_checks = { + "enable_mx_checks": parameters.get_value("enable_mx_checks"), + "enable_spf_checks": parameters.get_value("enable_spf_checks"), + "enable_dkim_checks": parameters.get_value("enable_dkim_checks"), + "enable_dmarc_checks": parameters.get_value("enable_dmarc_checks"), + "enable_autoconfig_checks": ( + parameters.get_value("enable_autoconfig_checks")), + "enable_dnsbl_checks": parameters.get_value("enable_dnsbl_checks") + } context["headers"] = render_to_string( - "admin/domain_headers.html", { - "enable_mx_checks": parameters.get_value("enable_mx_checks"), - "enable_dnsbl_checks": ( - parameters.get_value("enable_dnsbl_checks")) - }, request + "admin/domain_headers.html", dns_checks, request ) if page is None: context["length"] = 0 else: + tpl_context = {"domains": page.object_list} + tpl_context.update(dns_checks) context["rows"] = render_to_string( - "admin/domains_table.html", { - "domains": page.object_list, - "enable_mx_checks": parameters.get_value("enable_mx_checks"), - "enable_dnsbl_checks": ( - parameters.get_value("enable_dnsbl_checks")) - }, request + "admin/domains_table.html", tpl_context, request ) context["pages"] = [page.number] return render_to_json_response(context) @@ -104,6 +106,11 @@ def domains(request, tplname="admin/domains.html"): return render(request, tplname, { "selection": "domains", "enable_mx_checks": parameters.get_value("enable_mx_checks"), + "enable_spf_checks": parameters.get_value("enable_spf_checks"), + "enable_dkim_checks": parameters.get_value("enable_dkim_checks"), + "enable_dmarc_checks": parameters.get_value("enable_dmarc_checks"), + "enable_autoconfig_checks": ( + parameters.get_value("enable_autoconfig_checks")), "enable_dnsbl_checks": parameters.get_value("enable_dnsbl_checks") }) @@ -226,6 +233,11 @@ class DomainDetailView( context.update({ "templates": {"left": [], "right": []}, "enable_mx_checks": parameters.get_value("enable_mx_checks"), + "enable_spf_checks": parameters.get_value("enable_spf_checks"), + "enable_dkim_checks": parameters.get_value("enable_dkim_checks"), + "enable_dmarc_checks": parameters.get_value("enable_dmarc_checks"), + "enable_autoconfig_checks": ( + parameters.get_value("enable_autoconfig_checks")), "enable_dnsbl_checks": parameters.get_value("enable_dnsbl_checks"), }) for _receiver, widgets in result: diff --git a/modoboa/core/commands/templates/settings.py.tpl b/modoboa/core/commands/templates/settings.py.tpl index 3f7ec5cf8..0b16ce2ea 100644 --- a/modoboa/core/commands/templates/settings.py.tpl +++ b/modoboa/core/commands/templates/settings.py.tpl @@ -2,8 +2,6 @@ """ Django settings for {{ name }} project. -Generated by 'django-admin startproject' using Django 1.11.8. - For more information on this file, see https://docs.djangoproject.com/en/1.11/topics/settings/ @@ -82,6 +80,7 @@ MODOBOA_APPS = ( 'modoboa.relaydomains', 'modoboa.limits', 'modoboa.parameters', + 'modoboa.dnstools', # Modoboa extensions here. {% for extension in extensions %} '{{ extension }}', {% endfor %} diff --git a/modoboa/core/tests/test_views.py b/modoboa/core/tests/test_views.py index fb5e3e840..07c9606c3 100644 --- a/modoboa/core/tests/test_views.py +++ b/modoboa/core/tests/test_views.py @@ -69,7 +69,11 @@ SETTINGS_SAMPLE = { "admin-create_alias_on_mbox_rename": False, "admin-dkim_keys_storage_dir": "", "admin-dkim_default_key_length": 1024, - "admin-custom_dns_server": "193.43.55.67" + "admin-custom_dns_server": "193.43.55.67", + "admin-enable_spf_checks": True, + "admin-enable_dkim_checks": True, + "admin-enable_dmarc_checks": True, + "admin-enable_autoconfig_checks": True, } diff --git a/modoboa/dnstools/__init__.py b/modoboa/dnstools/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/modoboa/dnstools/apps.py b/modoboa/dnstools/apps.py new file mode 100644 index 000000000..d13d72ef1 --- /dev/null +++ b/modoboa/dnstools/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class DnstoolsConfig(AppConfig): + name = 'dnstools' diff --git a/modoboa/dnstools/constants.py b/modoboa/dnstools/constants.py new file mode 100644 index 000000000..c579193ee --- /dev/null +++ b/modoboa/dnstools/constants.py @@ -0,0 +1,29 @@ +"""App related constants.""" + +import re + + +DNS_RECORD_TYPES = [ + ("spf", "SPF"), + ("dkim", "DKIM"), + ("dmarc", "DMARC"), + ("autoconfig", "Autoconfig"), + ("autodiscover", "Autodiscover"), +] + +SPF_MECHANISMS = ["ip4", "ip6", "a", "mx", "ptr", "exists", "include"] + +DMARC_URI_REGEX = re.compile(r"^mailto:(.+)(!\w+)?") + +DMARC_TAGS = { + "adkim": {"values": ["r", "s"]}, + "aspf": {"values": ["r", "s"]}, + "fo": {"values": ["0", "1", "d", "s"]}, + "p": {"values": ["none", "quarantine", "reject"]}, + "pct": {"type": "int", "min_value": 0, "max_value": 100}, + "rf": {"type": "list", "values": ["afrf"]}, + "ri": {"type": "int"}, + "rua": {"type": "list", "regex": DMARC_URI_REGEX}, + "ruf": {"type": "list", "regex": DMARC_URI_REGEX}, + "sp": {"values": ["none", "quarantine", "reject"]} +} diff --git a/modoboa/dnstools/lib.py b/modoboa/dnstools/lib.py new file mode 100644 index 000000000..aedeb0fb6 --- /dev/null +++ b/modoboa/dnstools/lib.py @@ -0,0 +1,264 @@ +"""dnstools library.""" + +from __future__ import unicode_literals + +import ipaddress + +from django.utils.translation import ugettext as _ + +from modoboa.admin import lib as admin_lib + +from . import constants + + +def get_spf_record(domain): + """Return SPF record for domain (if any).""" + records = admin_lib.get_dns_records(domain, "TXT") + if records is None: + return None + for record in records: + value = str(record).strip('"') + if value.startswith("v=spf1"): + return value + return None + + +def get_dkim_record(domain, selector): + """Return DKIM records form domain (if any).""" + name = "{}._domainkey.{}".format(selector, domain) + records = admin_lib.get_dns_records(name, "TXT") + if records is None: + return None + for record in records: + value = str(record).strip('"') + if value.startswith("v=DKIM1"): + return value + return None + + +def get_dmarc_record(domain): + """Return DMARC record for domain (if any).""" + name = "_dmarc.{}".format(domain) + records = admin_lib.get_dns_records(name, "TXT") + if records is None: + return None + for record in records: + value = str(record).strip('"') + if value.startswith("v=DMARC1"): + return value + return None + + +def _get_simple_record(name): + """We just want to know if name is declared.""" + for rdtype in ["A", "CNAME", "AAAA"]: + records = admin_lib.get_dns_records(name, rdtype) + if records is not None: + break + else: + return None + for record in records: + value = str(record).strip('"') + break + return value + + +def get_autoconfig_record(domain): + """Return autoconfig record for domain (if any).""" + return _get_simple_record("autoconfig.{}".format(domain)) + + +def get_autodiscover_record(domain): + """Return autodiscover record for domain (if any).""" + return _get_simple_record("autodiscover.{}".format(domain)) + + +class DNSSyntaxError(Exception): + """Custom exception for DNS errors.""" + + pass + + +def check_spf_ip4(value): + """Check syntax of ip4 mechanism.""" + parts = value.split(":") + if len(parts) != 2: + raise DNSSyntaxError(_("Wrong ip4 mechanism syntax")) + try: + ipaddress.ip_address(parts[1]) + except ValueError: + raise DNSSyntaxError(_("Wrong IPv4 address format")) + + +def check_spf_ip6(value): + """Check syntax of ip6 mechanism.""" + if not value.startswith("ip6:"): + raise DNSSyntaxError(_("Wrong ip6 mechanism syntax")) + value = value.replace("ip6:", "") + try: + ipaddress.ip_address(value) + except ValueError: + raise DNSSyntaxError(_("Wrong IPv6 address format")) + + +def _check_domain_and_mask(value, mechanism): + """Check for valid domain / mask.""" + domain = None + mask = None + if ":" in value: + mechanism, domain = value.split(":") + if "/" in domain: + domain, mask = domain.split("/") + elif "/" in value: + mechanism, mask = value.split("/") + else: + raise DNSSyntaxError( + _("Invalid syntax for {} mechanism").format(mechanism)) + if mask and (not mask.isdigit() or int(mask) > 32): + raise DNSSyntaxError(_("Invalid mask found {}").format(mask)) + + +def check_spf_a(value): + """Check syntax of a mechanism.""" + if value == "a": + return + _check_domain_and_mask(value, "a") + + +def check_spf_mx(value): + """Check syntax of mx mechanism.""" + if value == "mx": + return + _check_domain_and_mask(value, "mx") + + +def _check_simple(value, mechanism): + """Simple check.""" + if value == mechanism: + return + parts = value.split(":") + if len(parts) != 2: + raise DNSSyntaxError( + _("Invalid syntax for {} mechanism").format(mechanism)) + + +def check_spf_ptr(value): + """Check syntax of ptr mechanism.""" + _check_simple(value, "ptr") + + +def check_spf_exists(value): + """Check syntax of ptr mechanism.""" + _check_simple(value, "exists") + + +def check_spf_include(value): + """Check syntax of include mechanism.""" + _check_simple(value, "include") + + +def check_spf_syntax(record): + """Check if record has a valid SPF syntax.""" + if not record.startswith("v=spf1"): + raise DNSSyntaxError(_("Not an SPF record")) + parts = record.split(" ")[1:] + modifiers = [] + mechanisms = [] + for part in parts: + qualifier = None + if part[0] in ["+", "-", "~", "?"]: + qualifier = part[0] + part = part[1:] + if part == "all": + continue + for mechanism in constants.SPF_MECHANISMS: + if part.startswith(mechanism): + globals()["check_spf_{}".format(mechanism)](part) + mechanisms.append(mechanism) + break + else: + # Look for modifier + modifier = part.split("=") + if len(modifier) != 2: + raise DNSSyntaxError(_("Unknown mechanism {}").format(part)) + if modifier[0] not in ["redirect", "exp"]: + raise DNSSyntaxError(_("Unknown modifier {}").format( + modifier[0])) + if modifier[0] in modifiers: + raise DNSSyntaxError(_("Duplicate modifier {} found").format( + modifier[0])) + modifiers.append(modifier[0]) + if not len(mechanisms) and not len(modifiers): + raise DNSSyntaxError(_("No mechanism found")) + return None + + +def check_dkim_syntax(record): + """Check if record has a valid DKIM syntax.""" + if not record.startswith("v=DKIM1"): + raise DNSSyntaxError(_("Not a valid DKIM record")) + key = None + for tag in record.split(";")[1:]: + tag = tag.strip(" ") + parts = tag.split("=") + if len(parts) != 2: + raise DNSSyntaxError(_("Invalid tag {}").format(tag)) + name = parts[0].strip(" ") + if name == "p": + key = "".join(part.strip('"') for part in parts[1].split(" ")) + if key is None: + raise DNSSyntaxError(_("No key found in record")) + return key + + +def check_dmarc_tag_string_value(tag, value): + """Check if value is valid for tag.""" + tdef = constants.DMARC_TAGS[tag] + error = _("Wrong value {} for tag {}").format(value, tag) + if "values" in tdef and value not in tdef["values"]: + raise DNSSyntaxError(error) + elif "regex" in tdef and tdef["regex"].match(value) is None: + raise DNSSyntaxError(error) + + +def check_dmarc_tag(tag, value): + """Check if tag is valid.""" + tdef = constants.DMARC_TAGS[tag] + ttype = tdef.get("type", "string") + if ttype == "list": + for svalue in value.split(","): + check_dmarc_tag_string_value(tag, svalue) + elif ttype == "int": + error = _("Wrong value {} for tag {}:").format(value, tag) + try: + value = int(value) + except ValueError: + raise DNSSyntaxError(error + _(" not an integer")) + if "min_value" in tdef and value < tdef["min_value"]: + raise DNSSyntaxError( + error + _(" less than {}").format(tdef["min_value"])) + if "max_value" in tdef and value > tdef["max_value"]: + raise DNSSyntaxError( + error + _(" greater than {}").format(tdef["max_value"])) + else: + check_dmarc_tag_string_value(tag, value) + + +def check_dmarc_syntax(record): + """Check if record has a valid DMARC syntax.""" + if not record.startswith("v=DMARC1"): + raise DNSSyntaxError(_("Not a valid DMARC record")) + tags = {} + for tag in record.split(";")[1:]: + tag = tag.strip(" ") + parts = tag.split("=") + if len(parts) != 2: + raise DNSSyntaxError(_("Invalid tag {}").format(tag)) + name = parts[0].strip(" ") + if name not in constants.DMARC_TAGS: + raise DNSSyntaxError(_("Unknown tag {}").format(name)) + value = parts[1].strip(" ") + check_dmarc_tag(name, value) + tags[name] = value + if "p" not in tags: + raise DNSSyntaxError(_("Missing required p tag")) diff --git a/modoboa/dnstools/migrations/0001_initial.py b/modoboa/dnstools/migrations/0001_initial.py new file mode 100644 index 000000000..c71fdcfbb --- /dev/null +++ b/modoboa/dnstools/migrations/0001_initial.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.16 on 2018-11-19 10:07 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('admin', '0014_auto_20181017_1628'), + ] + + operations = [ + migrations.CreateModel( + name='DNSRecord', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('type', models.CharField(choices=[('spf', 'SPF'), ('dkim', 'DKIM'), ('dmarc', 'DMARC'), ('autoconfig', 'Autoconfig')], max_length=10)), + ('value', models.TextField(blank=True)), + ('is_valid', models.BooleanField(default=False)), + ('error', models.CharField(blank=True, max_length=50, null=True)), + ('updated', models.DateTimeField(default=django.utils.timezone.now)), + ('domain', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='admin.Domain')), + ], + ), + ] diff --git a/modoboa/dnstools/migrations/__init__.py b/modoboa/dnstools/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/modoboa/dnstools/models.py b/modoboa/dnstools/models.py new file mode 100644 index 000000000..2a9f05c2c --- /dev/null +++ b/modoboa/dnstools/models.py @@ -0,0 +1,85 @@ +"""App related models.""" + +from __future__ import unicode_literals + +import datetime + +from django.db import models +from django.utils import timezone +from django.utils.encoding import python_2_unicode_compatible +from django.utils.translation import ugettext as _ + +from . import constants +from . import lib + + +class DNSRecordManager(models.Manager): + """Custom manager for DNSRecord.""" + + def get_or_create_for_domain(self, domain, rtype, ttl=7200): + """Get or create DNS record for given domain. + + DNS queries are not performed while `ttl` (in seconds) is still valid. + """ + now = timezone.now() + record = self.get_queryset().filter( + domain=domain, type=rtype, updated__gt=now).first() + if record: + return record + + self.get_queryset().filter(domain=domain, type=rtype).delete() + record = DNSRecord(domain=domain, type=rtype) + record.get_dns_record() + if not record.value: + return + record.check_syntax(ttl) + record.save() + return record + + +@python_2_unicode_compatible +class DNSRecord(models.Model): + """A model to store DNS records for Domain.""" + + domain = models.ForeignKey("admin.Domain", on_delete=models.CASCADE) + type = models.CharField( + max_length=10, choices=constants.DNS_RECORD_TYPES) + value = models.TextField(blank=True) + is_valid = models.BooleanField(default=False) + error = models.CharField(max_length=50, null=True, blank=True) + updated = models.DateTimeField(default=timezone.now) + + objects = DNSRecordManager() + + def __str__(self): + return "{} ({}): {}".format(self.domain, self.type, self.value) + + def get_dns_record(self): + """Retrieve corresponding DNS record.""" + if self.type == "dkim": + self.value = lib.get_dkim_record( + self.domain.name, self.domain.dkim_key_selector) + else: + func = getattr(lib, "get_{}_record".format(self.type)) + self.value = func(self.domain.name) + + def check_syntax(self, ttl=7200): + """Check record syntax.""" + try: + func = getattr(lib, "check_{}_syntax".format(self.type)) + result = func(self.value) + except lib.DNSSyntaxError as err: + self.error = str(err) + self.updated = timezone.now() + return + except AttributeError: + pass + + if self.type == "dkim" and result != self.dkim_public_key: + self.error = _("Public key mismatchs") + self.updated = timezone.now() + return + + self.error = "" + self.is_valid = True + self.updated = timezone.now() + datetime.timedelta(seconds=ttl) diff --git a/modoboa/dnstools/templates/dnstools/autoconfig_records_status.html b/modoboa/dnstools/templates/dnstools/autoconfig_records_status.html new file mode 100644 index 000000000..d8fe9959d --- /dev/null +++ b/modoboa/dnstools/templates/dnstools/autoconfig_records_status.html @@ -0,0 +1,24 @@ +{% extends "common/generic_modal.html" %} + +{% load i18n %} + +{% block modalbody %} + {% if object.autoconfig_record %} +
+ {% trans "autoconfig record (Mozilla) found: "%} {{ object.autoconfig_record.value }}. +
+ {% else %} +
+ {% trans "autoconfig record (Mozilla) not found." %} +
+ {% endif %} + {% if object.autodiscover_record %} +
+ {% trans "autodiscover record (Microsoft) found: " %} {{ object.autodiscover_record.value }}. +
+ {% else %} +
+ {% trans "autodiscover record (Microsoft) not found." %} +
+ {% endif %} +{% endblock %} diff --git a/modoboa/dnstools/templates/dnstools/dns_record_detail.html b/modoboa/dnstools/templates/dnstools/dns_record_detail.html new file mode 100644 index 000000000..dccd8066b --- /dev/null +++ b/modoboa/dnstools/templates/dnstools/dns_record_detail.html @@ -0,0 +1,16 @@ +{% extends "common/generic_modal.html" %} + +{% load i18n %} + +{% block modalbody %} + {% if object.is_valid %} +
+ {% trans "A DNS record has been found and is valid." %} +
+ {% else %} +
+ {% trans "A DNS record has been found but its syntax is not valid:" %} + {{ object.error }} +
+ {% endif %} +{% endblock %} diff --git a/modoboa/dnstools/tests.py b/modoboa/dnstools/tests.py new file mode 100644 index 000000000..ebed3ec3a --- /dev/null +++ b/modoboa/dnstools/tests.py @@ -0,0 +1,102 @@ +"""App related tests.""" + +from django.test import SimpleTestCase + +from . import lib + + +BAD_SPF_RECORDS = [ + ("a mx ~all", "Not an SPF record"), + ("v=spf1 toto ~all", "Unknown mechanism toto"), + ("v=spf1 ip4 -all", "Wrong ip4 mechanism syntax"), + ("v=spf1 +ip4:1.1.1. +all", "Wrong IPv4 address format"), + ("v=spf1 ip6 ~all", "Wrong ip6 mechanism syntax"), + ("v=spf1 +ip6:x:: +all", "Wrong IPv6 address format"), + ("v=spf1 a|domain.com ~all", "Invalid syntax for a mechanism"), + ("v=spf1 a:domain.com/TOTO", "Invalid mask found TOTO"), + ("v=spf1 a/1000", "Invalid mask found 1000"), + ("v=spf1 mx:domain.com/TOTO", "Invalid mask found TOTO"), + ("v=spf1 mx/1000", "Invalid mask found 1000"), + ("v=spf1 ptr|test.com", "Invalid syntax for ptr mechanism"), + ("v=spf1 forward=test.com ~all", "Unknown modifier forward"), + ("v=spf1 redirect=test.com redirect=test.com ~all", + "Duplicate modifier redirect found"), + ("v=spf1", "No mechanism found"), +] + +GOOD_SPF_RECORDS = [ + "v=spf1 mx", + "v=spf1 a ~all", + "v=spf1 ip4:1.2.3.4 -all", + # "v=spf1 ip4:1.2.3.4/16 -all", + "v=spf1 ip6:fe80::9eb6:d0ff:fe8e:2807 -all", + # "v=spf1 ip6:fe80::9eb6:d0ff:fe8e:2807/64 -all", + "v=spf1 a -all", + "v=spf1 a:example.com -all", + "v=spf1 a:mailers.example.com -all", + "v=spf1 a/24 a:offsite.example.com/24 -all", + "v=spf1 mx mx:deferrals.domain.com -all", + "v=spf1 mx/24 mx:offsite.domain.com/24 -all", + "v=spf1 ptr -all", + "v=spf1 ptr:otherdomain.com -all", + "v=spf1 exists:example.com -all", + "v=spf1 include:example.com -all", + "v=spf1 ?include:example.com -all", + "v=spf1 redirect=example.com", + "v=spf1 exp=example.com", +] + +BAD_DMARC_RECORDS = [ + ("", "Not a valid DMARC record"), + ("v=DMARC1; test", "Invalid tag test"), + ("v=DMARC1; test=toto", "Unknown tag test"), + ("v=DMARC1; adkim=g", "Wrong value g for tag adkim"), + ("v=DMARC1; rua=mail:toto@test.com", + "Wrong value mail:toto@test.com for tag rua"), + ("v=DMARC1; rf=afrf,toto", "Wrong value toto for tag rf"), + ("v=DMARC1; ri=XXX", "Wrong value XXX for tag ri: not an integer"), + ("v=DMARC1; pct=-1", "Wrong value -1 for tag pct: less than 0"), + ("v=DMARC1; pct=1000", "Wrong value 1000 for tag pct: greater than 100"), + ("v=DMARC1; pct=100", "Missing required p tag"), +] + +GOOD_DMARC_RECORDS = [ + "v=DMARC1; p=reject; pct=100", + "v=DMARC1; p=quarantine; sp=none; adkim=s; aspf=s; " + "rua=mailto:dmarc-aggrep@ngyn.org; ruf=mailto:toto@test.com!24m; " + "rf=afrf; pct=100; ri=86400" +] + +BAD_DKIM_RECORDS = [ + ("", "Not a valid DKIM record"), + ("v=DKIM1; toto;p=XXX", "Invalid tag toto"), + ("v=DKIM1;k=rsa", "No key found in record"), +] + + +class TestCase(SimpleTestCase): + """Test test library.""" + + def test_check_spf_syntax(self): + for record in BAD_SPF_RECORDS: + with self.assertRaises(lib.DNSSyntaxError) as ctx: + lib.check_spf_syntax(record[0]) + self.assertEqual(str(ctx.exception), record[1]) + for record in GOOD_SPF_RECORDS: + lib.check_spf_syntax(record) + + def test_check_dmarc_syntax(self): + for record in BAD_DMARC_RECORDS: + with self.assertRaises(lib.DNSSyntaxError) as ctx: + lib.check_dmarc_syntax(record[0]) + self.assertEqual(str(ctx.exception), record[1]) + for record in GOOD_DMARC_RECORDS: + lib.check_dmarc_syntax(record) + + def test_check_dkim_syntax(self): + for record in BAD_DKIM_RECORDS: + with self.assertRaises(lib.DNSSyntaxError) as ctx: + lib.check_dkim_syntax(record[0]) + self.assertEqual(str(ctx.exception), record[1]) + key = lib.check_dkim_syntax("v=DKIM1;p=XXX123") + self.assertEqual(key, "XXX123") diff --git a/modoboa/dnstools/urls.py b/modoboa/dnstools/urls.py new file mode 100644 index 000000000..b7c1f8496 --- /dev/null +++ b/modoboa/dnstools/urls.py @@ -0,0 +1,14 @@ +"""App related urls.""" + +from django.conf.urls import url + +from . import views + + +urlpatterns = [ + url(r'^dnsrecords/(?P\d+)/$', views.DNSRecordDetailView.as_view(), + name="dns_record_detail"), + url(r'^dnsrecords/autoconfig/(?P\d+)/$', + views.AutoConfigRecordsStatusView.as_view(), + name="autoconfig_records_status"), +] diff --git a/modoboa/dnstools/views.py b/modoboa/dnstools/views.py new file mode 100644 index 000000000..b6ea4c9c0 --- /dev/null +++ b/modoboa/dnstools/views.py @@ -0,0 +1,67 @@ +"""App related views.""" + +from django.utils.translation import ugettext as _ +from django.views import generic + +from django.contrib.auth import mixins as auth_mixins + +from modoboa.admin import models as admin_models + +from . import models + + +class DomainAccessRequiredMixin(auth_mixins.AccessMixin): + """Check if user can access domain.""" + + def dispatch(self, request, *args, **kwargs): + if not request.user.can_access(self.get_object()): + return self.handle_no_permission() + return super(DomainAccessRequiredMixin, self).dispatch( + request, *args, **kwargs) + + +class DNSRecordDetailView( + auth_mixins.LoginRequiredMixin, + DomainAccessRequiredMixin, + generic.DetailView): + """View to display MX records.""" + + model = models.DNSRecord + template_name = "dnstools/dns_record_detail.html" + + def get_context_data(self, **kwargs): + """Add extra variables.""" + context = super(DNSRecordDetailView, self).get_context_data(**kwargs) + context.update({ + "title": _("{} record of {}").format( + self.object.type.upper(), self.object.domain) + }) + return context + + +class AutoConfigRecordsStatusView( + auth_mixins.LoginRequiredMixin, + auth_mixins.PermissionRequiredMixin, + generic.DetailView): + """Autoconfig records status view.""" + + model = admin_models.Domain + permission_required = "admin.view_domain" + template_name = "dnstools/autoconfig_records_status.html" + + def get_queryset(self): + """Add some prefetching.""" + return ( + admin_models.Domain.objects.get_for_admin(self.request.user) + .prefetch_related("dnsrecord_set") + ) + + def get_context_data(self, **kwargs): + """Add extra data to context.""" + context = super(AutoConfigRecordsStatusView, self).get_context_data( + **kwargs) + context.update({ + "title": _("Auto configuration records for {}").format( + self.object.name) + }) + return context diff --git a/modoboa/urls.py b/modoboa/urls.py index 650816886..e8fe24b79 100644 --- a/modoboa/urls.py +++ b/modoboa/urls.py @@ -29,6 +29,7 @@ urlpatterns = [ url("", include("modoboa.core.urls", namespace="core")), url("^user/forward/", forward, name="user_forward"), url("admin/", include("modoboa.admin.urls", namespace="admin")), + url("dnstools/", include("modoboa.dnstools.urls", namespace="dnstools")), # No namespace url(r'^accounts/password_reset/$', core_views.password_reset, name="password_reset"), diff --git a/test_project/test_project/settings.py b/test_project/test_project/settings.py index 364b38f22..3c20ea65a 100644 --- a/test_project/test_project/settings.py +++ b/test_project/test_project/settings.py @@ -69,6 +69,7 @@ MODOBOA_APPS = ( "modoboa.relaydomains", "modoboa.limits", "modoboa.parameters", + "modoboa.dnstools", # Modoboa extensions here. )