Imported Rspamd to main repo

This commit is contained in:
Adrien P
2025-06-17 11:06:58 +02:00
committed by Antoine Nguyen
parent aa5898f4be
commit 10983c8218
22 changed files with 437 additions and 2 deletions
+7
View File
@@ -0,0 +1,7 @@
import repository from './repository'
export default {
getCapabilities() {
return repository.get(`/capabilities/`)
},
}
+18 -1
View File
@@ -3,16 +3,20 @@
</template>
<script setup>
import { computed, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useGettext } from 'vue3-gettext'
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 authStore = useAuthStore()
const parametersStore = useParametersStore()
const isRspamdVisible = ref(false)
const rspamd_url = ref('/rspamd')
const authUser = computed(() => authStore.authUser)
const imapMigrationEnabled = computed(
() => parametersStore.imapMigrationEnabled
@@ -89,6 +93,13 @@ const adminMenuItems = [
children: settings,
roles: ['SuperAdmins'],
},
{
icon: 'mdi-email-check-outline',
text: 'Rspamd',
to: rspamd_url.value,
roles: ['SuperAdmins'],
condition: () => isRspamdVisible.value,
},
{
icon: 'mdi-information',
text: $gettext('Information'),
@@ -114,6 +125,12 @@ 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'
}
})
}
})
</script>
+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()})
+11
View File
@@ -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
+15
View File
@@ -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"
+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):
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
+111
View File
@@ -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,
)
+38
View File
@@ -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()
+29
View File
@@ -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}")
@@ -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")
+6
View File
@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models
# Create your models here.
+6
View File
@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.test import TestCase
# Create your tests here.
+24
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.shortcuts import render
# Create your views here.
+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",