New DNS checks.

see #1618
This commit is contained in:
Antoine Nguyen
2018-11-19 18:54:12 +01:00
parent 38408db0b3
commit 277602e3f0
24 changed files with 830 additions and 54 deletions
+33
View File
@@ -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
View File
@@ -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)
+26 -1
View File
@@ -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 %}
-1
View File
@@ -453,4 +453,3 @@ class DKIMTestCase(ModoTestCase):
os.unlink(key_path)
call_command("modo", "manage_dkim_keys")
self.assertTrue(os.path.exists(key_path))
+6 -6
View File
@@ -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"}),
+23 -11
View File
@@ -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 %}
+5 -1
View File
@@ -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,
}
View File
+5
View File
@@ -0,0 +1,5 @@
from django.apps import AppConfig
class DnstoolsConfig(AppConfig):
name = 'dnstools'
+29
View File
@@ -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"]}
}
+264
View File
@@ -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')),
],
),
]
+85
View File
@@ -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 %}
+102
View File
@@ -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")
+14
View File
@@ -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"),
]
+67
View File
@@ -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
+1
View File
@@ -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"),
+1
View File
@@ -69,6 +69,7 @@ MODOBOA_APPS = (
"modoboa.relaydomains",
"modoboa.limits",
"modoboa.parameters",
"modoboa.dnstools",
# Modoboa extensions here.
)