mirror of
https://github.com/jwinterm/modoboa.git
synced 2026-10-19 05:58:34 +02:00
Merge pull request #3490 from modoboa/feature/add-rspamd
Import Rspamd to main repo
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
import repository from './repository'
|
||||
|
||||
export default {
|
||||
getCapabilities() {
|
||||
return repository.get(`/capabilities/`)
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -37,4 +37,5 @@ urlpatterns += [
|
||||
views.NotificationsAPIView.as_view(),
|
||||
name="notifications",
|
||||
),
|
||||
path("capabilities/", views.CapabilitiesAPIView.as_view(), name="capabilities"),
|
||||
]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
"""The rspamd frontend of Modoboa."""
|
||||
|
||||
default_app_config = "modoboa.rspamd.apps.ModoboaRspamdConfig"
|
||||
@@ -0,0 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
@@ -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
|
||||
@@ -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."
|
||||
),
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
},
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user