From 10983c8218ba58763c3206e183c492e7303ae9cc Mon Sep 17 00:00:00 2001 From: Adrien P Date: Mon, 3 Mar 2025 16:40:49 +0100 Subject: [PATCH] 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",