diff --git a/modoboa/admin/lib.py b/modoboa/admin/lib.py index 932a1504e..93785064d 100644 --- a/modoboa/admin/lib.py +++ b/modoboa/admin/lib.py @@ -2,9 +2,15 @@ """Internal library for admin.""" +import socket +import ipaddress +from datetime import datetime +from datetime import timedelta from functools import wraps from itertools import chain +import dns.resolver + from django.contrib.contenttypes.models import ContentType from django.db.models import Q from django.utils.translation import ugettext as _ @@ -14,7 +20,7 @@ from modoboa.core import signals as core_signals from modoboa.lib import events from modoboa.lib.exceptions import PermDeniedException -from .models import Domain, DomainAlias, Alias +from .models import Domain, DomainAlias, Alias, MXRecord def needs_mailbox(): @@ -100,6 +106,42 @@ def get_domains(user, domfilter=None, searchquery=None, **extrafilters): return domains +def get_mx_records_for_domain(domain, ttl=7200): + try: + answers = dns.resolver.query(domain.name, "MX") + except dns.resolver.NoAnswer: + return + now = datetime.now() + delta = timedelta(seconds=ttl) + records = MXRecord.objects.filter(domain=domain, + updated__gte=now - delta) + if records.count(): + print('skip update') + for record in records: + yield record + raise StopIteration() + MXRecord.objects.filter(domain=domain).delete() + for answer in answers: + address = None + try: + ipaddress.ip_address(str(answer.exchange)) + except ValueError: + try: + address = socket.gethostbyname(str(answer.exchange)) + except socket.gaierror: + pass + else: + address = str(answer.exchange) + finally: + if address is not None: + record = MXRecord.objects.create( + domain=domain, + name=str(answer.exchange).strip('.'), + address=address, + updated=now) + yield record + + def check_if_domain_exists(name, extra_checks=None): """Check if a domain already exists. diff --git a/modoboa/admin/management/commands/modo.py b/modoboa/admin/management/commands/modo.py index b5aba2f6d..1edebd35e 100644 --- a/modoboa/admin/management/commands/modo.py +++ b/modoboa/admin/management/commands/modo.py @@ -3,6 +3,7 @@ from subcommand.base import SubcommandCommand from .subcommands._dnsbl import CheckDNSBLCommand +from .subcommands._mx import CheckMXRecords from .subcommands._export import ExportCommand from .subcommands._import import ImportCommand @@ -18,5 +19,6 @@ class Command(SubcommandCommand): subcommands = { "export": ExportCommand, "import": ImportCommand, + "check_mx": CheckMXRecords, "check_dnsbl": CheckDNSBLCommand, } diff --git a/modoboa/admin/management/commands/subcommands/_dnsbl.py b/modoboa/admin/management/commands/subcommands/_dnsbl.py index ff0a5d24c..7b9f09612 100644 --- a/modoboa/admin/management/commands/subcommands/_dnsbl.py +++ b/modoboa/admin/management/commands/subcommands/_dnsbl.py @@ -1,8 +1,5 @@ """Management command to check defined domains against DNSBL providers.""" -import ipaddress - -import dns.resolver import gevent from gevent import socket @@ -14,6 +11,7 @@ from django.utils.translation import ugettext as _ from modoboa.admin import constants from modoboa.admin import models +from modoboa.admin.lib import get_mx_records_for_domain from modoboa.lib import parameters from modoboa.lib import email_utils @@ -41,37 +39,37 @@ class CheckDNSBLCommand(BaseCommand): "--timeout", type=int, default=3, help="Timeout used for queries.") - def query(self, ip_list, provider): + def query(self, mx_list, provider): """Check given IP against given provider.""" results = {} - for ip in ip_list: - reverse = ".".join(reversed(ip.split("."))) + for mx in mx_list: + reverse = ".".join(reversed(mx.address.split("."))) pattern = "{}.{}".format(reverse, provider) try: - results[ip] = socket.gethostbyname(pattern) + results[mx] = socket.gethostbyname(pattern) except socket.gaierror: - results[ip] = False + results[mx] = False return provider, results def store_domain_result(self, domain, provider, results): """Store provider results for domain.""" alerts = {} to_create = [] - for ip in results.keys(): - result = "" if not results[ip] else results[ip] + for mx in results.keys(): + result = "" if not results[mx] else results[mx] dnsbl_result = models.DNSBLResult.objects.filter( - domain=domain, provider=provider, mx=ip).first() + domain=domain, provider=provider, mx=mx).first() if dnsbl_result is None: to_create.append( models.DNSBLResult( - domain=domain, provider=provider, mx=ip, + domain=domain, provider=provider, mx=mx, status=result) ) else: if not dnsbl_result.status and result: if domain not in alerts: alerts[domain] = [] - alerts[domain].append((provider, ip)) + alerts[domain].append((provider, mx)) dnsbl_result.status = result dnsbl_result.save() models.DNSBLResult.objects.bulk_create(to_create) @@ -90,34 +88,13 @@ class CheckDNSBLCommand(BaseCommand): self.sender, admin.email, domainsubject=subject, content=content) if not status: - print msg + print(msg) def check_domain(self, domain, timeout): """Check specified domain.""" - resolver = dns.resolver.Resolver() - try: - answers = resolver.query(domain.name, "MX") - except dns.resolver.NoAnswer: - return - ip_list = [] - for answer in answers: - address = None - try: - ipaddress.ip_address(str(answer.exchange)) - except ValueError: - try: - address = socket.gethostbyname(str(answer.exchange)) - except socket.gaierror: - pass - else: - address = str(answer.exchange) - finally: - if address is not None: - ip_list.append(address) - if len(ip_list) == 0: - return + mx_list = get_mx_records_for_domain(domain) jobs = [ - gevent.spawn(self.query, ip_list, provider) + gevent.spawn(self.query, mx_list, provider) for provider in self.providers] gevent.joinall(jobs, timeout) for job in jobs: diff --git a/modoboa/admin/management/commands/subcommands/_mx.py b/modoboa/admin/management/commands/subcommands/_mx.py new file mode 100644 index 000000000..32ee6395f --- /dev/null +++ b/modoboa/admin/management/commands/subcommands/_mx.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +"""Management command to check defined domains against MX records.""" + +from django.core.management.base import BaseCommand + +from modoboa.admin import models +from modoboa.admin.lib import get_mx_records_for_domain + + +class CheckMXRecords(BaseCommand): + """Command class.""" + + help = "Check defined domains against MX records." + + def handle(self, *args, **options): + """Command entry point.""" + for domain in models.Domain.objects.all(): + for record in get_mx_records_for_domain(domain): + print(record) diff --git a/modoboa/admin/migrations/0007_mxrecord.py b/modoboa/admin/migrations/0007_mxrecord.py new file mode 100644 index 000000000..759b5b203 --- /dev/null +++ b/modoboa/admin/migrations/0007_mxrecord.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.9 on 2016-09-13 09:16 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('admin', '0006_auto_20160910_1030'), + ] + + operations = [ + migrations.CreateModel( + name='MXRecord', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=254)), + ('address', models.GenericIPAddressField()), + ('managed', models.BooleanField(default=False)), + ('updated', models.DateTimeField()), + ('domain', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='admin.Domain')), + ], + ), + ] diff --git a/modoboa/admin/migrations/0008_auto_20160913_1414.py b/modoboa/admin/migrations/0008_auto_20160913_1414.py new file mode 100644 index 000000000..53e6d69bb --- /dev/null +++ b/modoboa/admin/migrations/0008_auto_20160913_1414.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.9 on 2016-09-13 14:14 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('admin', '0007_mxrecord'), + ] + + operations = [ + migrations.AlterField( + model_name='dnsblresult', + name='mx', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='admin.MXRecord'), + ), + ] diff --git a/modoboa/admin/models/__init__.py b/modoboa/admin/models/__init__.py index 29205eec3..07a1569ea 100644 --- a/modoboa/admin/models/__init__.py +++ b/modoboa/admin/models/__init__.py @@ -1,7 +1,7 @@ """Admin models.""" from .base import AdminObject -from .domain import Domain, DNSBLResult +from .domain import Domain, MXRecord, DNSBLResult from .domain_alias import DomainAlias from .mailbox import Mailbox, Quota, MailboxOperation, SenderAddress from .alias import Alias, AliasRecipient @@ -15,6 +15,7 @@ __all__ = [ "DomainAlias", "Mailbox", "MailboxOperation", + "MXRecord", "Quota", "SenderAddress", ] diff --git a/modoboa/admin/models/domain.py b/modoboa/admin/models/domain.py index d51a95d8d..fd6d39f3a 100644 --- a/modoboa/admin/models/domain.py +++ b/modoboa/admin/models/domain.py @@ -102,6 +102,10 @@ class Domain(AdminObject): def aliases(self): return self.domainalias_set + @property + def mx_records(self): + self.mxrecord_set + @cached_property def dnsbl_status_color(self): """Shortcut to DNSBL results.""" @@ -220,6 +224,18 @@ class Domain(AdminObject): reversion.register(Domain) +class MXRecord(models.Model): + + domain = models.ForeignKey(Domain) + name = models.CharField(max_length=254) + address = models.GenericIPAddressField() + managed = models.BooleanField(default=False) + updated = models.DateTimeField() + + def __unicode__(self): + return u'{0.name} ({0.address}) for {0.domain} '.format(self) + + class DNSBLQuerySet(models.QuerySet): """Custom manager for DNSBLResultManager.""" @@ -233,7 +249,7 @@ class DNSBLResult(models.Model): domain = models.ForeignKey(Domain) provider = models.CharField(max_length=254, db_index=True) - mx = models.GenericIPAddressField() + mx = models.ForeignKey(MXRecord) status = models.CharField(max_length=45, blank=True, db_index=True) objects = models.Manager.from_queryset(DNSBLQuerySet)()