From 10983c8218ba58763c3206e183c492e7303ae9cc Mon Sep 17 00:00:00 2001 From: Adrien P Date: Mon, 3 Mar 2025 16:40:49 +0100 Subject: [PATCH 1/8] Imported Rspamd to main repo --- frontend/src/api/capabilities.js | 7 ++ frontend/src/layouts/admin/AdminLayout.vue | 19 ++- modoboa/core/api/v2/urls.py | 1 + modoboa/core/api/v2/views.py | 12 +- modoboa/core/utils.py | 11 ++ modoboa/rspamd/__init__.py | 15 +++ modoboa/rspamd/admin.py | 6 + modoboa/rspamd/api/__init__.py | 0 modoboa/rspamd/api/v2/__init__.py | 0 modoboa/rspamd/api/v2/serializers.py | 47 ++++++++ modoboa/rspamd/app_settings.py | 111 ++++++++++++++++++ modoboa/rspamd/apps.py | 38 ++++++ modoboa/rspamd/handlers.py | 29 +++++ modoboa/rspamd/management/__init__.py | 0 .../rspamd/management/commands/__init__.py | 0 .../management/commands/manage_rspamd_maps.py | 100 ++++++++++++++++ modoboa/rspamd/migrations/__init__.py | 0 modoboa/rspamd/models.py | 6 + modoboa/rspamd/tests.py | 6 + modoboa/rspamd/utils.py | 24 ++++ modoboa/rspamd/views.py | 6 + test_project/test_project/settings.py | 1 + 22 files changed, 437 insertions(+), 2 deletions(-) create mode 100644 frontend/src/api/capabilities.js create mode 100644 modoboa/rspamd/__init__.py create mode 100644 modoboa/rspamd/admin.py create mode 100644 modoboa/rspamd/api/__init__.py create mode 100644 modoboa/rspamd/api/v2/__init__.py create mode 100644 modoboa/rspamd/api/v2/serializers.py create mode 100644 modoboa/rspamd/app_settings.py create mode 100644 modoboa/rspamd/apps.py create mode 100644 modoboa/rspamd/handlers.py create mode 100644 modoboa/rspamd/management/__init__.py create mode 100644 modoboa/rspamd/management/commands/__init__.py create mode 100644 modoboa/rspamd/management/commands/manage_rspamd_maps.py create mode 100644 modoboa/rspamd/migrations/__init__.py create mode 100644 modoboa/rspamd/models.py create mode 100644 modoboa/rspamd/tests.py create mode 100644 modoboa/rspamd/utils.py create mode 100644 modoboa/rspamd/views.py 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..115af58c2 100644 --- a/frontend/src/layouts/admin/AdminLayout.vue +++ b/frontend/src/layouts/admin/AdminLayout.vue @@ -3,16 +3,20 @@ 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..d3903687d 100644 --- a/modoboa/core/utils.py +++ b/modoboa/core/utils.py @@ -13,6 +13,7 @@ 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 check_rspamd_installed def parse_map_file(path): @@ -101,3 +102,13 @@ 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 + rspamd_options = check_rspamd_installed() + if rspamd_options is not None: + capabilities.update({"rspamd": rspamd_options}) + return capabilities diff --git a/modoboa/rspamd/__init__.py b/modoboa/rspamd/__init__.py new file mode 100644 index 000000000..e9bd3d7f1 --- /dev/null +++ b/modoboa/rspamd/__init__.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- + +"""The rspamd frontend of Modoboa.""" + +from __future__ import unicode_literals + +from pkg_resources import DistributionNotFound, get_distribution + +try: + __version__ = get_distribution(__name__).version +except DistributionNotFound: + # package is not installed + pass + +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..140780b4b --- /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): + 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..c06e6030e --- /dev/null +++ b/modoboa/rspamd/app_settings.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- + +"""Modoboa rspamd forms.""" + +import collections + +from django import forms +from django.utils.translation import gettext_lazy as _ + +from modoboa.lib import form_utils +from modoboa.parameters import forms as param_forms + +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." + ), + }, + ), + ], + ), + }, + ), + ] +) + + +class ParametersForm(param_forms.AdminParametersForm): + """Extension settings.""" + + app = "modoboa.rspamd" + + dkim_settings_sep = form_utils.SeparatorField(label=_("DKIM signing settings")) + key_map_path = forms.CharField( + label=_("Key map path"), + initial="", + help_text=_( + "Absolute path of the file which contains paths to DKIM " + "private keys. " + "Must be readable by _rspamd group or user." + ), + required=False, + ) + selector_map_path = forms.CharField( + label=_("Selector map path"), + initial="", + help_text=_( + "Absolute path of the file which contains names of " + "DKIM selectors. " + "Must be readable by _rspamd group or user." + ), + required=False, + ) + + miscellaneous_sep = form_utils.SeparatorField(label=_("Miscellaneous")) + rspamd_dashboard_location = forms.CharField( + label=_("Rspamd dashboard location"), + initial="/rspamd", + 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." + ), + required=False, + ) diff --git a/modoboa/rspamd/apps.py b/modoboa/rspamd/apps.py new file mode 100644 index 000000000..eaf8bf274 --- /dev/null +++ b/modoboa/rspamd/apps.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +from django.apps import AppConfig +from django.utils.translation import gettext_lazy + +from .utils import check_rspamd_installed + + +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.add( + "global", app_settings.ParametersForm, gettext_lazy("Rspamd") + ) + 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): + if check_rspamd_installed(): + 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..ee68de66b --- /dev/null +++ b/modoboa/rspamd/handlers.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- + +"""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..4446bb234 --- /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): + config = dict(param_tools.get_global_parameters("modoboa.rspamd")) + if not config["key_map_path"] or not 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(config["key_map_path"], "r") 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(config["selector_map_path"], "r") 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) + config = dict(param_tools.get_global_parameters("modoboa.rspamd")) + + if self.modified_selector_file: + with open(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(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/models.py b/modoboa/rspamd/models.py new file mode 100644 index 000000000..1dfab7604 --- /dev/null +++ b/modoboa/rspamd/models.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models + +# Create your models here. diff --git a/modoboa/rspamd/tests.py b/modoboa/rspamd/tests.py new file mode 100644 index 000000000..5982e6bcd --- /dev/null +++ b/modoboa/rspamd/tests.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.test import TestCase + +# Create your tests here. diff --git a/modoboa/rspamd/utils.py b/modoboa/rspamd/utils.py new file mode 100644 index 000000000..aace6b6bf --- /dev/null +++ b/modoboa/rspamd/utils.py @@ -0,0 +1,24 @@ +"""Utilies for modoboa.rspamd plugin.""" + +import shutil + +from django.core.exceptions import AppRegistryNotReady + +from modoboa.lib import signals as lib_signals + + +def check_rspamd_installed(): + try: + is_rspamd_installed = shutil.which("rspamd") is not None + rspamd_options = {} if is_rspamd_installed else None + request = lib_signals.get_request() + if is_rspamd_installed: + if request is not None: + rspamd_options["location"] = request.localconfig.parameters.get_value( + "rspamd_dashboard_location" + ) + else: + rspamd_options["location"] = None + except AppRegistryNotReady: + return None + return rspamd_options diff --git a/modoboa/rspamd/views.py b/modoboa/rspamd/views.py new file mode 100644 index 000000000..e784a0bd2 --- /dev/null +++ b/modoboa/rspamd/views.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.shortcuts import render + +# Create your views here. diff --git a/test_project/test_project/settings.py b/test_project/test_project/settings.py index fe6ba6b42..75e1092c1 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", From dcd27d5833fd0c8de8f439121ec1e0350f6fa34f Mon Sep 17 00:00:00 2001 From: Adrien P Date: Mon, 3 Mar 2025 17:21:02 +0100 Subject: [PATCH 2/8] Refactored rspamd capabilities checks --- modoboa/core/utils.py | 7 ++-- .../management/commands/manage_rspamd_maps.py | 1 + modoboa/rspamd/utils.py | 32 +++++++++++-------- 3 files changed, 24 insertions(+), 16 deletions(-) diff --git a/modoboa/core/utils.py b/modoboa/core/utils.py index d3903687d..820ded898 100644 --- a/modoboa/core/utils.py +++ b/modoboa/core/utils.py @@ -13,7 +13,7 @@ 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 check_rspamd_installed +from modoboa.rspamd.utils import check_rspamd_installed, get_rspamd_options def parse_map_file(path): @@ -108,7 +108,8 @@ def get_capabilities(): """Return the list of capabilities of this modoboa instance.""" capabilities = {} # Rspamd - rspamd_options = check_rspamd_installed() - if rspamd_options is not None: + is_rspamd_installed = check_rspamd_installed() + if is_rspamd_installed: + rspamd_options = get_rspamd_options() capabilities.update({"rspamd": rspamd_options}) return capabilities diff --git a/modoboa/rspamd/management/commands/manage_rspamd_maps.py b/modoboa/rspamd/management/commands/manage_rspamd_maps.py index 4446bb234..c4cf4474a 100644 --- a/modoboa/rspamd/management/commands/manage_rspamd_maps.py +++ b/modoboa/rspamd/management/commands/manage_rspamd_maps.py @@ -1,4 +1,5 @@ """Management command to create DKIM keys.""" + from django.core.management.base import BaseCommand, CommandError from modoboa.parameters import tools as param_tools diff --git a/modoboa/rspamd/utils.py b/modoboa/rspamd/utils.py index aace6b6bf..14d014070 100644 --- a/modoboa/rspamd/utils.py +++ b/modoboa/rspamd/utils.py @@ -4,21 +4,27 @@ import shutil from django.core.exceptions import AppRegistryNotReady -from modoboa.lib import signals as lib_signals - def check_rspamd_installed(): + return shutil.which("rspamd") is not None + + +def get_rspamd_options(): try: - is_rspamd_installed = shutil.which("rspamd") is not None - rspamd_options = {} if is_rspamd_installed else None - request = lib_signals.get_request() - if is_rspamd_installed: - if request is not None: - rspamd_options["location"] = request.localconfig.parameters.get_value( - "rspamd_dashboard_location" - ) - else: - rspamd_options["location"] = None + from modoboa.core import models + + is_rspamd_installed = check_rspamd_installed() + if not is_rspamd_installed: + return None + 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 except AppRegistryNotReady: + # If the instance is not restarted after rspamd installation return None - return rspamd_options From 8e28176c04d28041a59c33dbe4c4adb0e72d4264 Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Sat, 10 May 2025 16:26:24 +0200 Subject: [PATCH 3/8] Defer rspamdUrl access --- frontend/src/layouts/admin/AdminLayout.vue | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/frontend/src/layouts/admin/AdminLayout.vue b/frontend/src/layouts/admin/AdminLayout.vue index 115af58c2..18f529a7e 100644 --- a/frontend/src/layouts/admin/AdminLayout.vue +++ b/frontend/src/layouts/admin/AdminLayout.vue @@ -5,17 +5,19 @@ From 288809eb84def338aff922821650c614d9a3af12 Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Tue, 17 Jun 2025 16:52:41 +0200 Subject: [PATCH 4/8] Added test --- modoboa/rspamd/__init__.py | 12 ----- modoboa/rspamd/api/v2/serializers.py | 2 +- modoboa/rspamd/app_settings.py | 46 ------------------ modoboa/rspamd/apps.py | 11 +---- modoboa/rspamd/handlers.py | 2 - .../management/commands/manage_rspamd_maps.py | 13 +++-- modoboa/rspamd/models.py | 6 --- modoboa/rspamd/tests.py | 48 +++++++++++++++++-- modoboa/rspamd/utils.py | 9 ---- modoboa/rspamd/views.py | 6 --- test_project/test_project/settings.py | 1 - 11 files changed, 53 insertions(+), 103 deletions(-) delete mode 100644 modoboa/rspamd/models.py delete mode 100644 modoboa/rspamd/views.py diff --git a/modoboa/rspamd/__init__.py b/modoboa/rspamd/__init__.py index e9bd3d7f1..44eb36e16 100644 --- a/modoboa/rspamd/__init__.py +++ b/modoboa/rspamd/__init__.py @@ -1,15 +1,3 @@ -# -*- coding: utf-8 -*- - """The rspamd frontend of Modoboa.""" -from __future__ import unicode_literals - -from pkg_resources import DistributionNotFound, get_distribution - -try: - __version__ = get_distribution(__name__).version -except DistributionNotFound: - # package is not installed - pass - default_app_config = "modoboa.rspamd.apps.ModoboaRspamdConfig" diff --git a/modoboa/rspamd/api/v2/serializers.py b/modoboa/rspamd/api/v2/serializers.py index 140780b4b..8e67aec50 100644 --- a/modoboa/rspamd/api/v2/serializers.py +++ b/modoboa/rspamd/api/v2/serializers.py @@ -7,7 +7,7 @@ from django.utils.translation import gettext_lazy as _ from rest_framework import serializers -def validate_file_path(value): +def validate_file_path(value: str) -> None: if value: if os.path.isfile(value): if not os.access(value, os.W_OK): diff --git a/modoboa/rspamd/app_settings.py b/modoboa/rspamd/app_settings.py index c06e6030e..59da1d894 100644 --- a/modoboa/rspamd/app_settings.py +++ b/modoboa/rspamd/app_settings.py @@ -1,14 +1,9 @@ -# -*- coding: utf-8 -*- - """Modoboa rspamd forms.""" import collections -from django import forms from django.utils.translation import gettext_lazy as _ -from modoboa.lib import form_utils -from modoboa.parameters import forms as param_forms RSPAMD_PARAMETERS_STRUCT = collections.OrderedDict( [ @@ -68,44 +63,3 @@ RSPAMD_PARAMETERS_STRUCT = collections.OrderedDict( ), ] ) - - -class ParametersForm(param_forms.AdminParametersForm): - """Extension settings.""" - - app = "modoboa.rspamd" - - dkim_settings_sep = form_utils.SeparatorField(label=_("DKIM signing settings")) - key_map_path = forms.CharField( - label=_("Key map path"), - initial="", - help_text=_( - "Absolute path of the file which contains paths to DKIM " - "private keys. " - "Must be readable by _rspamd group or user." - ), - required=False, - ) - selector_map_path = forms.CharField( - label=_("Selector map path"), - initial="", - help_text=_( - "Absolute path of the file which contains names of " - "DKIM selectors. " - "Must be readable by _rspamd group or user." - ), - required=False, - ) - - miscellaneous_sep = form_utils.SeparatorField(label=_("Miscellaneous")) - rspamd_dashboard_location = forms.CharField( - label=_("Rspamd dashboard location"), - initial="/rspamd", - 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." - ), - required=False, - ) diff --git a/modoboa/rspamd/apps.py b/modoboa/rspamd/apps.py index eaf8bf274..ca561a0e3 100644 --- a/modoboa/rspamd/apps.py +++ b/modoboa/rspamd/apps.py @@ -1,9 +1,6 @@ -# -*- coding: utf-8 -*- from django.apps import AppConfig from django.utils.translation import gettext_lazy -from .utils import check_rspamd_installed - def load_rspamd_settings(): """Load rspamd settings. @@ -15,9 +12,6 @@ def load_rspamd_settings(): from . import app_settings from .api.v2 import serializers - param_tools.registry.add( - "global", app_settings.ParametersForm, gettext_lazy("Rspamd") - ) param_tools.registry.add2( "global", "rspamd", @@ -32,7 +26,6 @@ class ModoboaRspamdConfig(AppConfig): verbose_name = "Modoboa connector for Rspamd." def ready(self): - if check_rspamd_installed(): - from . import handlers # NOQA:F401 + from . import handlers # NOQA:F401 - load_rspamd_settings() + load_rspamd_settings() diff --git a/modoboa/rspamd/handlers.py b/modoboa/rspamd/handlers.py index ee68de66b..b26d17a0e 100644 --- a/modoboa/rspamd/handlers.py +++ b/modoboa/rspamd/handlers.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - """Modoboa rspamd handlers.""" from django.core.management import call_command diff --git a/modoboa/rspamd/management/commands/manage_rspamd_maps.py b/modoboa/rspamd/management/commands/manage_rspamd_maps.py index c4cf4474a..af98ad8a6 100644 --- a/modoboa/rspamd/management/commands/manage_rspamd_maps.py +++ b/modoboa/rspamd/management/commands/manage_rspamd_maps.py @@ -10,8 +10,8 @@ class Command(BaseCommand): """Command class.""" def load_files(self): - config = dict(param_tools.get_global_parameters("modoboa.rspamd")) - if not config["key_map_path"] or not config["selector_map_path"]: + 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." @@ -19,7 +19,7 @@ class Command(BaseCommand): self.dkim_path_map = {} try: - with open(config["key_map_path"], "r") as f: + 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", "") @@ -27,7 +27,7 @@ class Command(BaseCommand): pass self.selector_map = {} try: - with open(config["selector_map_path"], "r") as f: + with open(self.config["selector_map_path"]) as f: for line in f: domain_name, selector = line.split() self.selector_map[domain_name] = selector @@ -89,13 +89,12 @@ class Command(BaseCommand): qset = models.Domain.objects.all() for domain in qset: self.manage_domain(domain) - config = dict(param_tools.get_global_parameters("modoboa.rspamd")) if self.modified_selector_file: - with open(config["selector_map_path"], "w") as f: + 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(config["key_map_path"], "w") as f: + 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/models.py b/modoboa/rspamd/models.py deleted file mode 100644 index 1dfab7604..000000000 --- a/modoboa/rspamd/models.py +++ /dev/null @@ -1,6 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import models - -# Create your models here. diff --git a/modoboa/rspamd/tests.py b/modoboa/rspamd/tests.py index 5982e6bcd..2f9678e60 100644 --- a/modoboa/rspamd/tests.py +++ b/modoboa/rspamd/tests.py @@ -1,6 +1,46 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals +import os +import shutil -from django.test import TestCase +from django.core import management -# Create your tests here. +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 setUp(self): + super().setUp() + 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 test_command(self): + 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.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)) + + def test_command_with_arg(self): + 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)) diff --git a/modoboa/rspamd/utils.py b/modoboa/rspamd/utils.py index 14d014070..782959d02 100644 --- a/modoboa/rspamd/utils.py +++ b/modoboa/rspamd/utils.py @@ -1,21 +1,12 @@ """Utilies for modoboa.rspamd plugin.""" -import shutil - from django.core.exceptions import AppRegistryNotReady -def check_rspamd_installed(): - return shutil.which("rspamd") is not None - - def get_rspamd_options(): try: from modoboa.core import models - is_rspamd_installed = check_rspamd_installed() - if not is_rspamd_installed: - return None rspamd_options = {} localconfig = models.LocalConfig.objects.first() rspamd_location = localconfig.parameters.get_value( diff --git a/modoboa/rspamd/views.py b/modoboa/rspamd/views.py deleted file mode 100644 index e784a0bd2..000000000 --- a/modoboa/rspamd/views.py +++ /dev/null @@ -1,6 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.shortcuts import render - -# Create your views here. diff --git a/test_project/test_project/settings.py b/test_project/test_project/settings.py index 75e1092c1..c815583ad 100644 --- a/test_project/test_project/settings.py +++ b/test_project/test_project/settings.py @@ -260,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" From 04f5d366684220438fc0093d28c3599cdd5650a1 Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Thu, 19 Jun 2025 09:30:18 +0200 Subject: [PATCH 5/8] Removed wrong import --- modoboa/core/utils.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/modoboa/core/utils.py b/modoboa/core/utils.py index 820ded898..32615d79a 100644 --- a/modoboa/core/utils.py +++ b/modoboa/core/utils.py @@ -8,12 +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 check_rspamd_installed, get_rspamd_options +from modoboa.rspamd.utils import get_rspamd_options def parse_map_file(path): @@ -90,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 @@ -108,7 +109,7 @@ def get_capabilities(): """Return the list of capabilities of this modoboa instance.""" capabilities = {} # Rspamd - is_rspamd_installed = check_rspamd_installed() + is_rspamd_installed = "modoboa.rspamd" in settings.MODOBOA_APPS if is_rspamd_installed: rspamd_options = get_rspamd_options() capabilities.update({"rspamd": rspamd_options}) From 5f3ccb6af2a9df1c64479cfc7492e990b109a71f Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Thu, 19 Jun 2025 11:24:55 +0200 Subject: [PATCH 6/8] Fixed test --- modoboa/parameters/api/v2/tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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") From 55cb8d984d16db24945aae13bd715e374a4dc629 Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Thu, 19 Jun 2025 16:16:24 +0200 Subject: [PATCH 7/8] Improved coverage --- modoboa/core/api/v2/tests.py | 9 +++++++++ modoboa/rspamd/tests.py | 14 ++++++++++++++ 2 files changed, 23 insertions(+) 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/rspamd/tests.py b/modoboa/rspamd/tests.py index 2f9678e60..be7aa5cf0 100644 --- a/modoboa/rspamd/tests.py +++ b/modoboa/rspamd/tests.py @@ -2,6 +2,7 @@ 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 @@ -44,3 +45,16 @@ class ManagementCommandTestCase(ModoAPITestCase): 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) From 0143e4dcaf75dad7d90e4c2b9d92b3f663a990d7 Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Thu, 19 Jun 2025 17:31:04 +0200 Subject: [PATCH 8/8] Improved coverage --- modoboa/rspamd/tests.py | 22 ++++++++++++++++++++-- modoboa/rspamd/utils.py | 24 +++++++++--------------- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/modoboa/rspamd/tests.py b/modoboa/rspamd/tests.py index be7aa5cf0..36b21ee8b 100644 --- a/modoboa/rspamd/tests.py +++ b/modoboa/rspamd/tests.py @@ -19,26 +19,44 @@ class ManagementCommandTestCase(ModoAPITestCase): """Reset test env.""" shutil.rmtree(self.workdir) - def setUp(self): - super().setUp() + 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() diff --git a/modoboa/rspamd/utils.py b/modoboa/rspamd/utils.py index 782959d02..26cd1986b 100644 --- a/modoboa/rspamd/utils.py +++ b/modoboa/rspamd/utils.py @@ -1,21 +1,15 @@ """Utilies for modoboa.rspamd plugin.""" -from django.core.exceptions import AppRegistryNotReady - def get_rspamd_options(): - try: - from modoboa.core import models + 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 - except AppRegistryNotReady: - # If the instance is not restarted after rspamd installation + 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