mirror of
https://github.com/jwinterm/modoboa.git
synced 2026-10-17 05:41:25 +02:00
@@ -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,
|
||||
|
||||
+44
-32
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -11,4 +11,37 @@
|
||||
{% if enable_dnsbl_checks %}
|
||||
<span class="label label-mini label-{{ domain.dnsbl_status_color }}"><a href="{% url 'admin:dnsbl_domain_detail' domain.pk %}" data-toggle="ajaxmodal">DNSBL</a></span>
|
||||
{% endif %}
|
||||
{% if enable_spf_checks %}
|
||||
{% if not domain.spf_record %}
|
||||
<span class="label label-mini label-danger" title="{% trans 'No record found' %}">SPF</span>
|
||||
{% else %}
|
||||
<span class="label label-mini label-{% if not domain.spf_record.is_valid %}warning{% else %}success{% endif %}">
|
||||
<a href="{% url 'dnstools:dns_record_detail' domain.spf_record.pk %}" data-toggle="ajaxmodal">SPF</a></span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if enable_dkim_checks and domain.enable_dkim %}
|
||||
{% if not domain.dkim_public_key %}
|
||||
<span class="label label-mini label-default" title="{% trans 'Waiting for key to be generated' %}">DKIM</span>
|
||||
{% else %}
|
||||
<span class="label label-mini label-{% if not domain.dkim_record %}danger{% elif not domain.dkim_record.is_valid %}warning{% else %}success{% endif %}">
|
||||
<a href="{% url 'dnstools:dns_record_detail' domain.dkim_record.pk %}" data-toggle="ajaxmodal">DKIM</a></span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if enable_dmarc_checks %}
|
||||
{% if not domain.dmarc_record %}
|
||||
<span class="label label-mini label-danger" title="{% trans 'No record found' %}">DMARC</span>
|
||||
{% else %}
|
||||
<span class="label label-mini label-{% if not domain.dmarc_record.is_valid %}warning{% else %}success{% endif %}">
|
||||
<a href="{% url 'dnstools:dns_record_detail' domain.dmarc_record.pk %}" data-toggle="ajaxmodal">DMARC</a></span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if enable_autoconfig_checks %}
|
||||
{% if not domain.autoconfig_record and not domain.autodiscover_record %}
|
||||
<span class="label label-mini label-danger" title="{% trans 'No record found' %}">autoconfig</span>
|
||||
{% elif not domain.autoconfig_record or not domain.autodiscover_record %}
|
||||
<span class="label label-mini label-warning"><a href="{% url 'dnstools:autoconfig_records_status' domain.pk %}" data-toggle="ajaxmodal">autoconfig</a></span>
|
||||
{% else %}
|
||||
<span class="label label-mini label-success"><a href="{% url 'dnstools:autoconfig_records_status' domain.pk %}" data-toggle="ajaxmodal">autoconfig</a></span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
@@ -453,4 +453,3 @@ class DKIMTestCase(ModoTestCase):
|
||||
os.unlink(key_path)
|
||||
call_command("modo", "manage_dkim_keys")
|
||||
self.assertTrue(os.path.exists(key_path))
|
||||
|
||||
@@ -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"}),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class DnstoolsConfig(AppConfig):
|
||||
name = 'dnstools'
|
||||
@@ -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"]}
|
||||
}
|
||||
@@ -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"))
|
||||
@@ -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')),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
@@ -0,0 +1,24 @@
|
||||
{% extends "common/generic_modal.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
|
||||
{% block modalbody %}
|
||||
{% if object.autoconfig_record %}
|
||||
<div class="alert alert-success">
|
||||
{% trans "autoconfig record (Mozilla) found: "%} {{ object.autoconfig_record.value }}.
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-warning">
|
||||
{% trans "autoconfig record (Mozilla) not found." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if object.autodiscover_record %}
|
||||
<div class="alert alert-success">
|
||||
{% trans "autodiscover record (Microsoft) found: " %} {{ object.autodiscover_record.value }}.
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-warning">
|
||||
{% trans "autodiscover record (Microsoft) not found." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,16 @@
|
||||
{% extends "common/generic_modal.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
|
||||
{% block modalbody %}
|
||||
{% if object.is_valid %}
|
||||
<div class="alert alert-success">
|
||||
{% trans "A DNS record has been found and is valid." %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-warning">
|
||||
{% trans "A DNS record has been found but its syntax is not valid:" %}
|
||||
{{ object.error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -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")
|
||||
@@ -0,0 +1,14 @@
|
||||
"""App related urls."""
|
||||
|
||||
from django.conf.urls import url
|
||||
|
||||
from . import views
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
url(r'^dnsrecords/(?P<pk>\d+)/$', views.DNSRecordDetailView.as_view(),
|
||||
name="dns_record_detail"),
|
||||
url(r'^dnsrecords/autoconfig/(?P<pk>\d+)/$',
|
||||
views.AutoConfigRecordsStatusView.as_view(),
|
||||
name="autoconfig_records_status"),
|
||||
]
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
|
||||
@@ -69,6 +69,7 @@ MODOBOA_APPS = (
|
||||
"modoboa.relaydomains",
|
||||
"modoboa.limits",
|
||||
"modoboa.parameters",
|
||||
"modoboa.dnstools",
|
||||
# Modoboa extensions here.
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user