diff --git a/frontend/src/api/capabilities.js b/frontend/src/api/capabilities.js new file mode 100644 index 000000000..0c95ce1cd --- /dev/null +++ b/frontend/src/api/capabilities.js @@ -0,0 +1,7 @@ +import repository from './repository' + +export default { + getCapabilities() { + return repository.get(`/capabilities/`) + }, +} diff --git a/frontend/src/layouts/admin/AdminLayout.vue b/frontend/src/layouts/admin/AdminLayout.vue index b4ea7248a..18f529a7e 100644 --- a/frontend/src/layouts/admin/AdminLayout.vue +++ b/frontend/src/layouts/admin/AdminLayout.vue @@ -3,16 +3,22 @@ diff --git a/modoboa/core/api/v2/tests.py b/modoboa/core/api/v2/tests.py index 3778a0d4b..cbd42b4b4 100644 --- a/modoboa/core/api/v2/tests.py +++ b/modoboa/core/api/v2/tests.py @@ -534,3 +534,12 @@ class FIDOViewSetTestCase(ModoAPITestCase): resp = self.client.post(url, data, format="json") self.assertEqual(resp.status_code, 200) self.assertIn("tokens", resp.json()) + + +class CapabilitiesAPITestCase(ModoAPITestCase): + + def test_get(self): + url = reverse("v2:capabilities") + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertIn("rspamd", response.json()["capabilities"]) diff --git a/modoboa/core/api/v2/urls.py b/modoboa/core/api/v2/urls.py index 6343380c9..729964c6d 100644 --- a/modoboa/core/api/v2/urls.py +++ b/modoboa/core/api/v2/urls.py @@ -37,4 +37,5 @@ urlpatterns += [ views.NotificationsAPIView.as_view(), name="notifications", ), + path("capabilities/", views.CapabilitiesAPIView.as_view(), name="capabilities"), ] diff --git a/modoboa/core/api/v2/views.py b/modoboa/core/api/v2/views.py index 5e2a6c6f0..b12872960 100644 --- a/modoboa/core/api/v2/views.py +++ b/modoboa/core/api/v2/views.py @@ -11,7 +11,7 @@ from rest_framework import permissions, response from rest_framework.views import APIView from modoboa.core import signals -from modoboa.core.utils import check_for_updates +from modoboa.core.utils import check_for_updates, get_capabilities from modoboa.lib.permissions import IsSuperUser from modoboa.lib.throttle import ( UserLesserDdosUser, @@ -161,3 +161,13 @@ class NotificationsAPIView(APIView): ) serializer = serializers.NotificationSerializer(notifications, many=True) return response.Response(serializer.data) + + +class CapabilitiesAPIView(APIView): + """Return the capability of this Modoboa instance.""" + permission_classes = [permissions.IsAuthenticated] + throttle_classes = [UserLesserDdosUser] + + def get(self, *args, **kwargs): + return response.Response({"capabilities": get_capabilities()}) + diff --git a/modoboa/core/utils.py b/modoboa/core/utils.py index 2ac933985..32615d79a 100644 --- a/modoboa/core/utils.py +++ b/modoboa/core/utils.py @@ -8,11 +8,13 @@ from typing import Optional from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa +from django.conf import settings from django.utils.translation import gettext as _ from modoboa.core.extensions import exts_pool from modoboa.core.password_hashers import get_configured_password_hasher, PasswordHasher from modoboa.lib.api_client import ModoAPIClient +from modoboa.rspamd.utils import get_rspamd_options def parse_map_file(path): @@ -89,7 +91,7 @@ def generate_rsa_private_key(storage_path: str) -> bool: return True -def check_for_deprecated_password_schemes() -> Optional[type[PasswordHasher]]: +def check_for_deprecated_password_schemes() -> Optional[type[PasswordHasher]]: # NOQA """Check if deprecated password scheme is still in use.""" from modoboa.core import models @@ -101,3 +103,14 @@ def check_for_deprecated_password_schemes() -> Optional[type[PasswordHasher]]: if models.User.objects.is_password_scheme_in_use(dhasher): return dhasher return None + + +def get_capabilities(): + """Return the list of capabilities of this modoboa instance.""" + capabilities = {} + # Rspamd + is_rspamd_installed = "modoboa.rspamd" in settings.MODOBOA_APPS + if is_rspamd_installed: + rspamd_options = get_rspamd_options() + capabilities.update({"rspamd": rspamd_options}) + return capabilities diff --git a/modoboa/parameters/api/v2/tests.py b/modoboa/parameters/api/v2/tests.py index b67688921..bc2cc7645 100644 --- a/modoboa/parameters/api/v2/tests.py +++ b/modoboa/parameters/api/v2/tests.py @@ -18,7 +18,7 @@ class GlobalParametersAPITestCase(ModoAPITestCase): resp = self.client.get(url) self.assertEqual(resp.status_code, 200) # core, admin, limits, pdf credentials, ... - self.assertEqual(len(resp.json()), 11) + self.assertEqual(len(resp.json()), 12) def test_get_structure(self): url = reverse("v2:parameter-global-structure") diff --git a/modoboa/rspamd/__init__.py b/modoboa/rspamd/__init__.py new file mode 100644 index 000000000..44eb36e16 --- /dev/null +++ b/modoboa/rspamd/__init__.py @@ -0,0 +1,3 @@ +"""The rspamd frontend of Modoboa.""" + +default_app_config = "modoboa.rspamd.apps.ModoboaRspamdConfig" diff --git a/modoboa/rspamd/admin.py b/modoboa/rspamd/admin.py new file mode 100644 index 000000000..13be29d96 --- /dev/null +++ b/modoboa/rspamd/admin.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.contrib import admin + +# Register your models here. diff --git a/modoboa/rspamd/api/__init__.py b/modoboa/rspamd/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/modoboa/rspamd/api/v2/__init__.py b/modoboa/rspamd/api/v2/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/modoboa/rspamd/api/v2/serializers.py b/modoboa/rspamd/api/v2/serializers.py new file mode 100644 index 000000000..8e67aec50 --- /dev/null +++ b/modoboa/rspamd/api/v2/serializers.py @@ -0,0 +1,47 @@ +"""Modoboa rspamd serializer for api v2.""" + +import os + +from django.utils.translation import gettext_lazy as _ + +from rest_framework import serializers + + +def validate_file_path(value: str) -> None: + if value: + if os.path.isfile(value): + if not os.access(value, os.W_OK): + # File exists and is writable by modoboa + raise serializers.ValidationError(_("Provided path is not writable.")) + elif not os.path.isdir(os.path.dirname(value)): + raise serializers.ValidationError( + _("Please create the parent directory for the files") + ) + elif not os.access(os.path.dirname(value), os.W_OK): + # File doesn't exist and modoboa won't be able to create it... + raise serializers.ValidationError(_("Unable to write to the directory.")) + + +class RspamdSettingsSerializer(serializers.Serializer): + """A serializer for global parameters.""" + + # dkim_settings_sep + key_map_path = serializers.CharField( + default="/var/lib/dkim/keys.path.map", allow_blank=True + ) + selector_map_path = serializers.CharField( + default="/var/lib/dkim/selectors.path.map", allow_blank=True + ) + + # miscellaneous_sep + rspamd_dashboard_location = serializers.CharField( + default="/rspamd", allow_blank=True + ) + + def validate_key_map_path(self, value): + validate_file_path(value) + return value + + def validate_selector_map_path(self, value): + validate_file_path(value) + return value diff --git a/modoboa/rspamd/app_settings.py b/modoboa/rspamd/app_settings.py new file mode 100644 index 000000000..59da1d894 --- /dev/null +++ b/modoboa/rspamd/app_settings.py @@ -0,0 +1,65 @@ +"""Modoboa rspamd forms.""" + +import collections + +from django.utils.translation import gettext_lazy as _ + + +RSPAMD_PARAMETERS_STRUCT = collections.OrderedDict( + [ + ( + "dkim_settings_sep", + { + "label": _("DKIM signing settings"), + "params": collections.OrderedDict( + [ + ( + "key_map_path", + { + "label": _("Key map path"), + "help_text": _( + "Absolute path of the file which contains " + "paths to DKIM private keys. " + "Must be readable by _rspamd group or user." + ), + }, + ), + ( + "selector_map_path", + { + "label": _("Selector map path"), + "help_text": _( + "Absolute path of the file which contains " + "names of DKIM selectors. " + "Must be readable by _rspamd group or user." + ), + }, + ), + ] + ), + }, + ), + ( + "miscellaneous_sep", + { + "label": _("Miscellaneous"), + "params": collections.OrderedDict( + [ + ( + "rspamd_dashboard_location", + { + "label": _("Rspamd dashboard location"), + "help_text": _( + "Location of the rspamd dashboard. " + "Can either be a relative URL (eg. '/rspamd') " + "or a full URL (eg. 'https://rspamd.domain.tld/')." + "Only use for guidance on the Super-Admin dashboard." + ), + }, + ), + ], + ), + }, + ), + ] +) diff --git a/modoboa/rspamd/apps.py b/modoboa/rspamd/apps.py new file mode 100644 index 000000000..ca561a0e3 --- /dev/null +++ b/modoboa/rspamd/apps.py @@ -0,0 +1,31 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy + + +def load_rspamd_settings(): + """Load rspamd settings. + + This function must be manually called (see :file:`urls.py`) in + order to load base settings. + """ + from modoboa.parameters import tools as param_tools + from . import app_settings + from .api.v2 import serializers + + param_tools.registry.add2( + "global", + "rspamd", + gettext_lazy("Rspamd"), + app_settings.RSPAMD_PARAMETERS_STRUCT, + serializers.RspamdSettingsSerializer, + ) + + +class ModoboaRspamdConfig(AppConfig): + name = "modoboa.rspamd" + verbose_name = "Modoboa connector for Rspamd." + + def ready(self): + from . import handlers # NOQA:F401 + + load_rspamd_settings() diff --git a/modoboa/rspamd/handlers.py b/modoboa/rspamd/handlers.py new file mode 100644 index 000000000..b26d17a0e --- /dev/null +++ b/modoboa/rspamd/handlers.py @@ -0,0 +1,27 @@ +"""Modoboa rspamd handlers.""" + +from django.core.management import call_command +from django.dispatch import receiver +from django.db.models.signals import post_save + +import django_rq + +from modoboa.admin import models as admin_models + + +@receiver(post_save, sender=admin_models.Domain) +def update_rspamd_dkim_maps(sender, instance, created, **kwargs): + """Update config maps.""" + # Modify or create + condition = ( + instance._loaded_values.get("dkim_private_key_path") + != instance.dkim_private_key_path + or instance._loaded_values.get("dkim_key_selector") + != instance.dkim_key_selector + or instance._loaded_values.get("enable_dkim") != instance.enable_dkim + ) + if condition: + # TODO : check how to handle it + # Either with modoboa user or _rspamd... + queue = django_rq.get_queue("modoboa") + queue.enqueue(call_command, "manage_rspamd_maps", f"--domain={instance.name}") diff --git a/modoboa/rspamd/management/__init__.py b/modoboa/rspamd/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/modoboa/rspamd/management/commands/__init__.py b/modoboa/rspamd/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/modoboa/rspamd/management/commands/manage_rspamd_maps.py b/modoboa/rspamd/management/commands/manage_rspamd_maps.py new file mode 100644 index 000000000..af98ad8a6 --- /dev/null +++ b/modoboa/rspamd/management/commands/manage_rspamd_maps.py @@ -0,0 +1,100 @@ +"""Management command to create DKIM keys.""" + +from django.core.management.base import BaseCommand, CommandError + +from modoboa.parameters import tools as param_tools +from modoboa.admin import models + + +class Command(BaseCommand): + """Command class.""" + + def load_files(self): + self.config = dict(param_tools.get_global_parameters("rspamd")) + if not self.config["key_map_path"] or not self.config["selector_map_path"]: + raise CommandError( + "path map path and/or selector map path " + "not set in modoboa rspamd settings." + ) + + self.dkim_path_map = {} + try: + with open(self.config["key_map_path"]) as f: + for line in f: + domain_name, path = line.split() + self.dkim_path_map[domain_name] = path.replace("\n", "") + except FileNotFoundError: + pass + self.selector_map = {} + try: + with open(self.config["selector_map_path"]) as f: + for line in f: + domain_name, selector = line.split() + self.selector_map[domain_name] = selector + except FileNotFoundError: + pass + + def manage_domain(self, domain_instance): + domain_name = domain_instance.name + selector_entry = self.selector_map.get(domain_name) + dkim_path_entry = self.dkim_path_map.get(domain_name) + if not domain_instance.enable_dkim: + if selector_entry is not None: + self.selector_map.pop(domain_name) + self.modified_selector_file = True + if dkim_path_entry is not None: + self.dkim_path_map.pop(domain_name) + self.modified_key_path_file = True + return + + # modify selector map + condition = ( + selector_entry is None + or selector_entry != domain_instance.dkim_key_selector + ) + if condition: + self.selector_map[domain_name] = domain_instance.dkim_key_selector + self.modified_selector_file = True + + # modify dkim path map + condition = ( + dkim_path_entry is None + or dkim_path_entry != domain_instance.dkim_private_key_path + ) + if condition: + self.dkim_path_map[domain_name] = domain_instance.dkim_private_key_path + self.modified_key_path_file = True + + def add_arguments(self, parser): + """Add arguments to command.""" + parser.add_argument( + "--domain", + type=str, + dest="domain", + default="", + help="Domain target for keys generation.", + ) + + def handle(self, *args, **options): + """Entry point.""" + self.modified_selector_file = False + self.modified_key_path_file = False + self.load_files() + + if options["domain"] != "": + domain = models.Domain.objects.filter(name=options["domain"]) + if domain.exists(): + self.manage_domain(domain[0]) + else: + qset = models.Domain.objects.all() + for domain in qset: + self.manage_domain(domain) + + if self.modified_selector_file: + with open(self.config["selector_map_path"], "w") as f: + for domain_name, selector in self.selector_map.items(): + f.write(f"{domain_name} {selector}\n") + if self.modified_key_path_file: + with open(self.config["key_map_path"], "w") as f: + for domain_name, key_path in self.dkim_path_map.items(): + f.write(f"{domain_name} {key_path}\n") diff --git a/modoboa/rspamd/migrations/__init__.py b/modoboa/rspamd/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/modoboa/rspamd/tests.py b/modoboa/rspamd/tests.py new file mode 100644 index 000000000..36b21ee8b --- /dev/null +++ b/modoboa/rspamd/tests.py @@ -0,0 +1,78 @@ +import os +import shutil + +from django.core import management +from django.urls import reverse + +from modoboa.admin import factories as admin_factories, models as admin_models +from modoboa.lib.tests import ModoAPITestCase + + +class ManagementCommandTestCase(ModoAPITestCase): + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + admin_factories.populate_database() + + def tearDown(self): + """Reset test env.""" + shutil.rmtree(self.workdir) + + def configure(self): + self.key_map_path = f"{self.workdir}/keys.map" + self.set_global_parameter("key_map_path", self.key_map_path) + self.selector_map_path = f"{self.workdir}/selectors.map" + self.set_global_parameter("selector_map_path", self.selector_map_path) + + def unconfigure(self): + self.set_global_parameter("key_map_path", "") + self.set_global_parameter("selector_map_path", "") + + def test_command(self): + self.unconfigure() + with self.assertRaises(management.CommandError): + management.call_command("manage_rspamd_maps") + + self.configure() + management.call_command("manage_rspamd_maps") + self.assertFalse(os.path.exists(self.key_map_path)) + self.assertFalse(os.path.exists(self.selector_map_path)) + + domain = admin_models.Domain.objects.get(name="test.com") + domain.enable_dkim = True + domain.dkim_private_key_path = "xxx" + domain.save() + management.call_command("manage_rspamd_maps") + self.assertTrue(os.path.exists(self.key_map_path)) + self.assertTrue(os.path.exists(self.selector_map_path)) + + # Now, empty map files + domain.enable_dkim = False + domain.save() + management.call_command("manage_rspamd_maps") + with open(self.key_map_path) as fp: + content = fp.read() + self.assertNotIn(domain.name, content) + + def test_command_with_arg(self): + self.configure() + domain = admin_models.Domain.objects.get(name="test.com") + domain.enable_dkim = True + domain.save() + management.call_command("manage_rspamd_maps", "--domain", "test.com") + self.assertTrue(os.path.exists(self.key_map_path)) + self.assertTrue(os.path.exists(self.selector_map_path)) + + +class ParametersAPITestCase(ModoAPITestCase): + + def test_update_settings(self): + url = reverse("v2:parameter-global-detail", args=["rspamd"]) + data = { + "key_map_path": f"{self.workdir}/key.map", + "selector_map_path": f"{self.workdir}/selector.map", + "rspamd_dashboard_location": "/rspamd", + } + response = self.client.put(url, data, format="json") + self.assertEqual(response.status_code, 200) diff --git a/modoboa/rspamd/utils.py b/modoboa/rspamd/utils.py new file mode 100644 index 000000000..26cd1986b --- /dev/null +++ b/modoboa/rspamd/utils.py @@ -0,0 +1,15 @@ +"""Utilies for modoboa.rspamd plugin.""" + + +def get_rspamd_options(): + from modoboa.core import models + + rspamd_options = {} + localconfig = models.LocalConfig.objects.first() + rspamd_location = localconfig.parameters.get_value( + "rspamd_dashboard_location", raise_exception=False + ) + if not rspamd_location: + return None + rspamd_options["location"] = rspamd_location + return rspamd_options diff --git a/test_project/test_project/settings.py b/test_project/test_project/settings.py index fe6ba6b42..c815583ad 100644 --- a/test_project/test_project/settings.py +++ b/test_project/test_project/settings.py @@ -99,6 +99,7 @@ MODOBOA_APPS = ( "modoboa.imap_migration", "modoboa.autoreply", "modoboa.sievefilters", + "modoboa.rspamd", # Modoboa extensions here. "modoboa.contacts", "modoboa.calendars", @@ -259,7 +260,6 @@ SPECTACULAR_SETTINGS = { # DOVECOT_LOOKUP_PATH = ('/path/to/dovecot', ) DOVECOT_USER = "root" - MODOBOA_API_URL = "https://api.modoboa.org/1/" PID_FILE_STORAGE_PATH = "/tmp"