Merge pull request #3490 from modoboa/feature/add-rspamd

Import Rspamd to main repo
This commit is contained in:
Antoine Nguyen
2025-06-19 17:43:08 +02:00
committed by GitHub
22 changed files with 441 additions and 5 deletions
+7
View File
@@ -0,0 +1,7 @@
import repository from './repository'
export default {
getCapabilities() {
return repository.get(`/capabilities/`)
},
}
+25 -1
View File
@@ -3,16 +3,22 @@
</template>
<script setup>
import { computed, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useGettext } from 'vue3-gettext'
import { useRouter } from 'vue-router'
import { useAuthStore, useParametersStore } from '@/stores'
import parametersApi from '@/api/parameters'
import capabilitiesApi from '@/api/capabilities'
import ConnectedLayout from '@/layouts/connected/ConnectedLayout.vue'
const { $gettext } = useGettext()
const router = useRouter()
const authStore = useAuthStore()
const parametersStore = useParametersStore()
const isRspamdVisible = ref(false)
const rspamdUrl = ref('/rspamd')
const authUser = computed(() => authStore.authUser)
const imapMigrationEnabled = computed(
() => parametersStore.imapMigrationEnabled
@@ -89,6 +95,13 @@ const adminMenuItems = [
children: settings,
roles: ['SuperAdmins'],
},
{
icon: 'mdi-email-check-outline',
text: 'Rspamd',
action: openRspamdDashboard,
roles: ['SuperAdmins'],
condition: () => isRspamdVisible.value,
},
{
icon: 'mdi-information',
text: $gettext('Information'),
@@ -114,6 +127,17 @@ onMounted(() => {
response.data.params.enabled_imapmigration
})
}
capabilitiesApi.getCapabilities().then((response) => {
isRspamdVisible.value = 'rspamd' in response.data.capabilities
if (isRspamdVisible.value) {
rspamd_url.value =
response.data.capabilities.rspamd.location || '/rspamd'
}
})
}
})
const openRspamdDashboard = () => {
router.push(rspamdUrl.value)
}
</script>
+9
View File
@@ -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"])
+1
View File
@@ -37,4 +37,5 @@ urlpatterns += [
views.NotificationsAPIView.as_view(),
name="notifications",
),
path("capabilities/", views.CapabilitiesAPIView.as_view(), name="capabilities"),
]
+11 -1
View File
@@ -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()})
+14 -1
View File
@@ -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
+1 -1
View File
@@ -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")
+3
View File
@@ -0,0 +1,3 @@
"""The rspamd frontend of Modoboa."""
default_app_config = "modoboa.rspamd.apps.ModoboaRspamdConfig"
+6
View File
@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.contrib import admin
# Register your models here.
View File
View File
+47
View File
@@ -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
+65
View File
@@ -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."
),
},
),
],
),
},
),
]
)
+31
View File
@@ -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()
+27
View File
@@ -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}")
@@ -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")
+78
View File
@@ -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)
+15
View File
@@ -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
+1 -1
View File
@@ -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"