mirror of
https://github.com/jwinterm/modoboa.git
synced 2026-10-21 06:18:45 +02:00
@@ -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()
|
||||
|
||||
@@ -99,3 +99,9 @@ DOMAIN_TYPES = [
|
||||
("domain", _("Domain")),
|
||||
("relaydomain", _("Relay domain")),
|
||||
]
|
||||
|
||||
DKIM_KEY_LENGTHS = [
|
||||
(1024, "1024"),
|
||||
(2048, "2048"),
|
||||
(4096, "4096"),
|
||||
]
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{% load i18n %}
|
||||
|
||||
<div class="modal-dialog {% block modal_css_classes %}{% endblock %}">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal"><span aria-hidden="true">×</span><span class="sr-only">{% trans "Close" %}</span></button>
|
||||
<h3 class="modal-title">{% blocktrans trimmed with domain=domain.name %}DKIM public key for {{ domain }}{% endblocktrans %}</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-info" style="overflow-wrap: break-word;">
|
||||
<strong>{% trans "Raw format" %}</strong><br>
|
||||
{{ domain.dkim_public_key }}
|
||||
</div>
|
||||
<div class="alert alert-info" style="overflow-wrap: break-word;">
|
||||
<strong>{% trans "Bind/named format" %}</strong><br>
|
||||
{{ domain.bind_format_dkim_public_key }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<a class="btn btn-default" data-dismiss="modal">{% trans "Close" %}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -25,14 +25,6 @@
|
||||
<h3 class="panel-title">{% trans "Summary" %}</h3>
|
||||
</div>
|
||||
<table class="table">
|
||||
{% if enable_mx_checks or enable_dnsbl_checks %}
|
||||
<tr>
|
||||
<th>{% trans "DNS status" %}</th>
|
||||
<td>
|
||||
{% include "admin/_domain_dnschecks_status.html" %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<th width="40%">{% trans "Creation date" %}</th>
|
||||
<td>{{ domain.creation }}</td>
|
||||
@@ -94,6 +86,33 @@
|
||||
</div>
|
||||
|
||||
<div class="col-xs-12 col-sm-6">
|
||||
<div class="panel panel-info">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">{% trans "DNS" %}</h3>
|
||||
</div>
|
||||
<table class="table">
|
||||
{% if enable_mx_checks or enable_dnsbl_checks %}
|
||||
<tr>
|
||||
<th>{% trans "Status" %}</th>
|
||||
<td>
|
||||
{% include "admin/_domain_dnschecks_status.html" %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<th>{% trans "DKIM key" %}</th>
|
||||
<td>
|
||||
{% if domain.dkim_public_key %}
|
||||
<button type="button" class="btn btn-primary btn-xs" data-toggle="modal" data-target="#domain_dkim_key">
|
||||
{% trans "Show key" %}
|
||||
</button>
|
||||
{% else %}
|
||||
{% trans "Not generated" %}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="panel panel-info">
|
||||
<div class="panel-heading"><h3 class="panel-title">{% trans "Administrators" %}</h3></div>
|
||||
<table id="domadmins" class="table">
|
||||
@@ -101,19 +120,19 @@
|
||||
<tr><th>{% trans "Username" %}</th><th>{% trans "Name" %}</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for da in domain.admins %}
|
||||
<tr>
|
||||
<td>{{ da.username }}</td>
|
||||
<td>{{ da.fullname }}</td>
|
||||
<td name="actions">
|
||||
{% if perms.admin.change_domain %}
|
||||
<a name="removeperm" href="{% url 'admin:permission_remove' %}?domid={{ domain.id }}&daid={{ da.id }}" title="{% trans 'Remove this permission' %}"><span class="fa fa-trash"></span></a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="3">{% trans "No domain administrator defined yet." %}</td></tr>
|
||||
{% endfor %}
|
||||
{% for da in domain.admins %}
|
||||
<tr>
|
||||
<td>{{ da.username }}</td>
|
||||
<td>{{ da.fullname }}</td>
|
||||
<td name="actions">
|
||||
{% if perms.admin.change_domain %}
|
||||
<a name="removeperm" href="{% url 'admin:permission_remove' %}?domid={{ domain.id }}&daid={{ da.id }}" title="{% trans 'Remove this permission' %}"><span class="fa fa-trash"></span></a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="3">{% trans "No domain administrator defined yet." %}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -123,4 +142,9 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if domain.dkim_public_key %}
|
||||
<div class="modal fade" id="domain_dkim_key" tabindex="-1" role="dialog" aria-labelledby="myModalLabel">
|
||||
{% include "admin/_domain_dkim_key.html" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -6,3 +6,6 @@
|
||||
{% render_fields_group form "aliases" %}
|
||||
{% render_field form.enabled %}
|
||||
{% render_field form.enable_dns_checks %}
|
||||
{% render_field form.enable_dkim %}
|
||||
{% render_field form.dkim_key_selector %}
|
||||
{% render_field form.dkim_key_length %}
|
||||
|
||||
@@ -4,9 +4,14 @@
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
import dns.resolver
|
||||
from mock import patch
|
||||
|
||||
from django.core.management import call_command
|
||||
from django.urls import reverse
|
||||
|
||||
from modoboa.core import factories as core_factories
|
||||
@@ -213,7 +218,8 @@ class DomainTestCase(ModoTestCase):
|
||||
mock_query.side_effect = utils.mock_dns_query_result
|
||||
mock_getaddrinfo.side_effect = utils.mock_ip_query_result
|
||||
values = {
|
||||
"name": "no-mx.example.com", "quota": 0, "default_mailbox_quota": 0,
|
||||
"name": "no-mx.example.com", "quota": 0,
|
||||
"default_mailbox_quota": 0,
|
||||
"create_dom_admin": True, "dom_admin_username": "toto",
|
||||
"create_aliases": True, "type": "domain", "stepid": "step3",
|
||||
"with_mailbox": True
|
||||
@@ -347,3 +353,45 @@ class DomainTestCase(ModoTestCase):
|
||||
content = response.content.decode('utf-8')
|
||||
self.assertNotIn("Global statistics", content)
|
||||
self.assertIn("Per-domain statistics", content)
|
||||
|
||||
|
||||
class DKIMTestCase(ModoTestCase):
|
||||
"""Test case for DKIM."""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls): # noqa: N802
|
||||
"""Create test data."""
|
||||
super(DKIMTestCase, cls).setUpTestData()
|
||||
factories.populate_database()
|
||||
|
||||
def setUp(self):
|
||||
super(DKIMTestCase, self).setUp()
|
||||
self.workdir = tempfile.mkdtemp()
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.workdir)
|
||||
|
||||
def test_dkim_key_creation(self):
|
||||
"""Test DKIM key creation."""
|
||||
values = {
|
||||
"name": "pouet.com", "quota": 1000, "default_mailbox_quota": 100,
|
||||
"create_dom_admin": False, "type": "domain", "stepid": "step3",
|
||||
"enable_dkim": True, "dkim_key_selector": ""
|
||||
}
|
||||
response = self.ajax_post(reverse("admin:domain_add"), values, 400)
|
||||
self.assertEqual(
|
||||
response["form_errors"]["enable_dkim"][0],
|
||||
"DKIM keys storage directory not configured")
|
||||
|
||||
self.set_global_parameter("dkim_keys_storage_dir", self.workdir)
|
||||
response = self.ajax_post(reverse("admin:domain_add"), values, 400)
|
||||
self.assertEqual(
|
||||
response["form_errors"]["dkim_key_selector"][0],
|
||||
"This field is required.")
|
||||
|
||||
values["dkim_key_selector"] = "default"
|
||||
self.ajax_post(reverse("admin:domain_add"), values)
|
||||
|
||||
call_command("modo", "manage_dkim_keys")
|
||||
key_path = os.path.join(self.workdir, "{}.pem".format(values["name"]))
|
||||
self.assertTrue(os.path.exists(key_path))
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.8 on 2018-01-12 15:42
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0014_auto_20171010_1746'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='language',
|
||||
field=models.CharField(choices=[('cs', '\u010de\u0161tina'), ('de', 'deutsch'), ('en', 'english'), ('el_GR', '\u03b5\u03bb\u03bb\u03b7\u03bd\u03b9\u03ba\u03ac'), ('es', 'espa\xf1ol'), ('fr', 'fran\xe7ais'), ('it', 'italiano'), ('ja_JP', '\u65e5\u672c\u306e'), ('nl', 'nederlands'), ('pt_PT', 'portugu\xeas'), ('pt_BR', 'portugu\xeas (BR)'), ('pl_PL', 'polski'), ('ru', '\u0440\u0443\u0441\u0441\u043a\u0438\u0439'), ('sv', 'svenska')], default='en', help_text='Prefered language to display pages.', max_length=10, verbose_name='language'),
|
||||
),
|
||||
]
|
||||
@@ -298,4 +298,4 @@ table td[name="actions"] {
|
||||
/* Dashboard */
|
||||
.slogan {
|
||||
padding-top: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user