diff --git a/modoboa/admin/app_settings.py b/modoboa/admin/app_settings.py index 95004aeab..3723df9b4 100644 --- a/modoboa/admin/app_settings.py +++ b/modoboa/admin/app_settings.py @@ -13,6 +13,8 @@ from modoboa.lib.form_utils import YesNoField, SeparatorField from modoboa.lib.sysutils import exec_cmd from modoboa.parameters import forms as param_forms +from . import constants + class AdminParametersForm(param_forms.AdminParametersForm): app = "admin" @@ -55,6 +57,25 @@ class AdminParametersForm(param_forms.AdminParametersForm): ) ) + dkim_keys_storage_dir = forms.CharField( + label=ugettext_lazy("DKIM keys storage directory"), + initial="", + help_text=ugettext_lazy( + "Absolute path of the directory where DKIM private keys will " + "be stored. Make sure this directory belongs to root user " + "and is not readable by the outside world." + ) + ) + + dkim_default_key_length = forms.ChoiceField( + label=ugettext_lazy("Default DKIM key length"), + initial=1024, + choices=constants.DKIM_KEY_LENGTHS, + help_text=ugettext_lazy( + "Default length for newly generated DKIM keys." + ) + ) + mbsep = SeparatorField(label=ugettext_lazy("Mailboxes")) handle_mailboxes = YesNoField( @@ -173,6 +194,22 @@ class AdminParametersForm(param_forms.AdminParametersForm): ) return self.cleaned_data["default_mailbox_quota"] + def clean_dkim_keys_storage_dir(self): + """Check that directory exists.""" + storage_dir = self.cleaned_data.get("dkim_keys_storage_dir", "") + if storage_dir: + if not os.path.isdir(storage_dir): + raise forms.ValidationError( + ugettext_lazy("Directory not found") + ) + code, output = exec_cmd("which openssl") + if code: + raise forms.ValidationError( + ugettext_lazy( + "openssl not found, please make sure it is installed") + ) + return storage_dir + def clean(self): """Check MX options.""" cleaned_data = super(AdminParametersForm, self).clean() diff --git a/modoboa/admin/constants.py b/modoboa/admin/constants.py index 42fdc1277..372f1ef20 100644 --- a/modoboa/admin/constants.py +++ b/modoboa/admin/constants.py @@ -99,3 +99,9 @@ DOMAIN_TYPES = [ ("domain", _("Domain")), ("relaydomain", _("Relay domain")), ] + +DKIM_KEY_LENGTHS = [ + (1024, "1024"), + (2048, "2048"), + (4096, "4096"), +] diff --git a/modoboa/admin/forms/domain.py b/modoboa/admin/forms/domain.py index 558b6e89f..ddd4bf070 100644 --- a/modoboa/admin/forms/domain.py +++ b/modoboa/admin/forms/domain.py @@ -43,12 +43,16 @@ class DomainFormGeneral(forms.ModelForm, DynamicForm): "press ENTER to add a new input." ) ) + dkim_key_selector = forms.CharField( + label=ugettext_lazy("Key selector"), required=False) class Meta: model = Domain fields = ( "name", "type", "quota", "default_mailbox_quota", "aliases", - "enabled", "enable_dns_checks") + "enabled", "enable_dns_checks", "enable_dkim", + "dkim_key_selector", "dkim_key_length" + ) def __init__(self, user, *args, **kwargs): self.oldname = None @@ -93,6 +97,17 @@ class DomainFormGeneral(forms.ModelForm, DynamicForm): return name + def clean_enable_dkim(self): + """Check prerequisites.""" + enabled = self.cleaned_data.get("enable_dkim") + if not enabled: + return enabled + storage_dir = param_tools.get_global_parameter("dkim_keys_storage_dir") + if not storage_dir: + raise forms.ValidationError( + _("DKIM keys storage directory not configured")) + return enabled + def clean(self): """Custom fields validation. @@ -123,6 +138,10 @@ class DomainFormGeneral(forms.ModelForm, DynamicForm): "default_mailbox_quota"] if default_mailbox_quota == 0: self.add_error("default_mailbox_quota", msg) + if self.cleaned_data["enable_dkim"]: + if not self.cleaned_data.get("dkim_key_selector"): + self.add_error( + "dkim_key_selector", _("This field is required.")) self.aliases = [] copied_data = cleaned_data.copy() for k in copied_data.keys(): diff --git a/modoboa/admin/management/commands/modo.py b/modoboa/admin/management/commands/modo.py index 5088ace23..d395c4c19 100644 --- a/modoboa/admin/management/commands/modo.py +++ b/modoboa/admin/management/commands/modo.py @@ -7,6 +7,7 @@ from subcommand.base import SubcommandCommand from .subcommands._mx import CheckMXRecords from .subcommands._export import ExportCommand from .subcommands._import import ImportCommand +from .subcommands._manage_dkim_keys import ManageDKIMKeys from .subcommands._repair import Repair @@ -22,5 +23,6 @@ class Command(SubcommandCommand): "export": ExportCommand, "import": ImportCommand, "check_mx": CheckMXRecords, + "manage_dkim_keys": ManageDKIMKeys, "repair": Repair, } diff --git a/modoboa/admin/management/commands/subcommands/_manage_dkim_keys.py b/modoboa/admin/management/commands/subcommands/_manage_dkim_keys.py new file mode 100644 index 000000000..1810a96af --- /dev/null +++ b/modoboa/admin/management/commands/subcommands/_manage_dkim_keys.py @@ -0,0 +1,51 @@ +"""Management command to create DKIM keys.""" + +from __future__ import print_function, unicode_literals + +import os + +from django.core.management.base import BaseCommand + +from modoboa.lib import sysutils +from modoboa.parameters import tools as param_tools + +from .... import models + + +class ManageDKIMKeys(BaseCommand): + """Command class.""" + + def create_new_dkim_key(self, domain): + """Create a new DKIM key.""" + storage_dir = param_tools.get_global_parameter("dkim_keys_storage_dir") + pkey_path = os.path.join(storage_dir, "{}.pem".format(domain.name)) + key_size = ( + domain.dkim_key_length if domain.dkim_key_length + else self.default_key_length) + code, output = sysutils.exec_cmd( + "openssl genrsa -out {} {}".format(pkey_path, key_size)) + if code: + print("Failed to generate DKIM private key for domain {}: {}" + .format(domain.name, output)) + domain.dkim_private_key_path = pkey_path + code, output = sysutils.exec_cmd( + "openssl rsa -in {} -pubout".format(pkey_path)) + if code: + print("Failed to generate DKIM public key for domain {}: {}" + .format(domain.name, output)) + public_key = "" + for cpt, line in enumerate(output.splitlines()): + if cpt == 0 or line.startswith("-----"): + continue + public_key += line + domain.dkim_public_key = public_key + domain.save(update_fields=["dkim_public_key", "dkim_private_key_path"]) + + def handle(self, *args, **options): + """Entry point.""" + self.default_key_length = param_tools.get_global_parameter( + "dkim_default_key_length") + qset = models.Domain.objects.filter( + enable_dkim=True, dkim_private_key_path="") + for domain in qset: + self.create_new_dkim_key(domain) diff --git a/modoboa/admin/migrations/0012_auto_20180112_1658.py b/modoboa/admin/migrations/0012_auto_20180112_1658.py new file mode 100644 index 000000000..ba9f1f071 --- /dev/null +++ b/modoboa/admin/migrations/0012_auto_20180112_1658.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.8 on 2018-01-12 15:58 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('admin', '0011_domain_transport'), + ] + + operations = [ + migrations.AddField( + model_name='domain', + name='dkim_key_length', + field=models.PositiveIntegerField(blank=True, choices=[(1024, '1024'), (2048, '2048'), (4096, '4096')], null=True, verbose_name='Key length'), + ), + migrations.AddField( + model_name='domain', + name='dkim_key_selector', + field=models.CharField(default='default', max_length=30, verbose_name='Key selector'), + ), + migrations.AddField( + model_name='domain', + name='dkim_private_key_path', + field=models.CharField(blank=True, max_length=254), + ), + migrations.AddField( + model_name='domain', + name='dkim_public_key', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='domain', + name='enable_dkim', + field=models.BooleanField(default=False, help_text='If you activate this feature, a DKIM key will be generated for this domain.', verbose_name='Enable DKIM signing'), + ), + ] diff --git a/modoboa/admin/models/domain.py b/modoboa/admin/models/domain.py index 04c1f3518..30a9e0219 100644 --- a/modoboa/admin/models/domain.py +++ b/modoboa/admin/models/domain.py @@ -58,6 +58,21 @@ class Domain(AdminObject): transport = models.OneToOneField( "transport.Transport", null=True, on_delete=models.SET_NULL) + enable_dkim = models.BooleanField( + ugettext_lazy("Enable DKIM signing"), + help_text=ugettext_lazy( + "If you activate this feature, a DKIM key will be " + "generated for this domain."), + default=False + ) + dkim_key_selector = models.CharField(max_length=30, default="default") + dkim_key_length = models.PositiveIntegerField( + ugettext_lazy("Key length"), choices=constants.DKIM_KEY_LENGTHS, + blank=True, null=True + ) + dkim_public_key = models.TextField(blank=True) + dkim_private_key_path = models.CharField(max_length=254, blank=True) + class Meta: permissions = ( ("view_domain", "View domain"), @@ -187,6 +202,12 @@ class Domain(AdminObject): return 0 return int(self.used_quota / float(self.quota) * 100) + @property + def bind_format_dkim_public_key(self): + """Return DKIM public key using Bind format.""" + return "{}._domainkey 10800 IN TXT \"v=DKIM1; k=rsa; p={}\"".format( + self.dkim_key_selector, self.dkim_public_key) + def add_admin(self, account): """Add a new administrator to this domain. diff --git a/modoboa/admin/static/admin/js/admin.js b/modoboa/admin/static/admin/js/admin.js index 47ce73aab..b198b9ab7 100644 --- a/modoboa/admin/static/admin/js/admin.js +++ b/modoboa/admin/static/admin/js/admin.js @@ -223,6 +223,21 @@ Domains.prototype = { "disabled", $target.val() === "False"); }, + /** + * Hide or show password inputs depending on value. + * + * @param {bool} value - visibility flag + */ + toggleDKIMFields: function (value) { + if (value) { + $('#id_dkim_key_selector').parents(".form-group").show(); + $('#id_dkim_key_length').parents(".form-group").show(); + } else { + $('#id_dkim_key_selector').parents(".form-group").hide(); + $('#id_dkim_key_length').parents(".form-group").hide(); + } + }, + /** * Initialize the main form contained in the domain edition modal. * @@ -239,6 +254,11 @@ Domains.prototype = { return true; } }); + $('#id_enable_dkim').click($.proxy(function (e) { + var $this = $(e.target); + this.toggleDKIMFields($this.prop('checked')); + }, this)); + this.toggleDKIMFields($('#id_enable_dkim').prop('checked')); }, optionsform_init: function() { diff --git a/modoboa/admin/templates/admin/_domain_dkim_key.html b/modoboa/admin/templates/admin/_domain_dkim_key.html new file mode 100644 index 000000000..d867ac509 --- /dev/null +++ b/modoboa/admin/templates/admin/_domain_dkim_key.html @@ -0,0 +1,23 @@ +{% load i18n %} + +