Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Hotfix/rspamd settings member alias #331

Merged
merged 3 commits into from
Sep 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions mailcow_integration/admin_status/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,7 @@ def get_context_data(self, **kwargs):
aliases = list(self.mailcow_manager.get_alias_all(use_cache=False))
mailboxes = list(self.mailcow_manager.get_mailbox_all(use_cache=False))
# Force cache update; we don't care about the result
self.mailcow_manager.get_internal_alias_rspamd_setting(use_cache=False)
self.mailcow_manager.get_internal_alias_rspamd_settings(use_cache=False)
except MailcowAuthException as e:
context["error"] = "No valid API key set."
except MailcowAPIReadWriteAccessDenied as e:
Expand All @@ -388,9 +388,10 @@ def get_context_data(self, **kwargs):
context["unused_aliases"] = self._init_unused_squire_addresses_list(
aliases, context["member_aliases"], context["committee_aliases"], context["global_committee_aliases"]
)
context["internal_alias_rspamd_setting"] = self.mailcow_manager.get_internal_alias_rspamd_setting(
use_cache=False
)
(
context["internal_alias_rspamd_setting_allow"],
context["internal_alias_rspamd_setting_block"],
) = self.mailcow_manager.get_internal_alias_rspamd_settings()
context["mailcow_host"] = self.mailcow_manager.mailcow_host
return context

Expand Down
111 changes: 68 additions & 43 deletions mailcow_integration/squire_mailcow.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
from enum import Enum
import re
from typing import Dict, Generator, Optional, List, Tuple
from typing import Dict, Optional, List, Tuple

from django.apps import apps
from django.conf import settings
from django.db.models import Q, QuerySet, Exists, OuterRef
from django.db.models import QuerySet, Exists, OuterRef
from django.template.loader import get_template

from mailcow_integration.api.client import MailcowAPIClient
Expand All @@ -28,7 +28,7 @@ def get_mailcow_manager() -> Optional["SquireMailcowManager"]:
class AliasCategory(Enum):
"""Squire's Mailcow Aliases can exist in different forms.
1. Member aliases are used to email all Squire Members active in the current year.
2. Global committee aliases are used to email all committees (Committtees and orders)
2. Global committee aliases are used to email all committees (Committees and orders)
registered in Squire.
3. Committee aliases are used to email a specific committee (or order).
"""
Expand All @@ -51,11 +51,13 @@ class SquireMailcowManager:
it also allows manual overrides by Mailcow admins.
"""

SQUIRE_MANAGE_INDICATOR = "[MANAGED BY SQUIRE]"
INTERNAL_ALIAS_SETTING_NAME = "%s Internal Alias" % SQUIRE_MANAGE_INDICATOR
ALIAS_COMMITTEE_PUBLIC_COMMENT = "%s Committee Alias" % SQUIRE_MANAGE_INDICATOR
ALIAS_MEMBERS_PUBLIC_COMMENT = "%s Members Alias" % SQUIRE_MANAGE_INDICATOR
ALIAS_GLOBAL_COMMITTEE_PUBLIC_COMMENT = "%s Global Committee Alias" % SQUIRE_MANAGE_INDICATOR
SQUIRE_MANAGE_INDICATOR = "[MANAGED BY SQUIRE]" if not settings.DEBUG else "[DEV][MANAGED BY SQUIRE]"
INTERNAL_ALIAS_SETTING_NAME = SQUIRE_MANAGE_INDICATOR + " Internal Alias (%s)"
INTERNAL_ALIAS_SETTING_WHITELIST_NAME = "Authenticated"
INTERNAL_ALIAS_SETTING_BLACKLIST_NAME = "Reject"
ALIAS_COMMITTEE_PUBLIC_COMMENT = f"{SQUIRE_MANAGE_INDICATOR} Committee Alias"
ALIAS_MEMBERS_PUBLIC_COMMENT = f"{SQUIRE_MANAGE_INDICATOR} Members Alias"
ALIAS_GLOBAL_COMMITTEE_PUBLIC_COMMENT = f"{SQUIRE_MANAGE_INDICATOR} Global Committee Alias"

def __init__(self, mailcow_host: str, mailcow_api_key: str):
self._client = MailcowAPIClient(mailcow_host, mailcow_api_key)
Expand All @@ -76,7 +78,8 @@ def __init__(self, mailcow_host: str, mailcow_api_key: str):
] + settings.COMMITTEE_CONFIGS["global_addresses"]

# Caches
self._internal_rspamd_setting: Optional[RspamdSettings] = None
self._internal_rspamd_setting_whitelist: Optional[RspamdSettings] = None
self._internal_rspamd_setting_blacklist: Optional[RspamdSettings] = None
self._alias_cache: Optional[List[MailcowAlias]] = None
self._mailbox_cache: Optional[List[MailcowMailbox]] = None
self._alias_map_cache: Optional[Dict[str, MailcowAlias]] = None
Expand Down Expand Up @@ -107,68 +110,68 @@ def clean_emails_flat(self, queryset: QuerySet, email_field="email", **kwargs) -
queryset = self.clean_emails(queryset, email_field, **kwargs)
return list(queryset.values_list(email_field, flat=True))

def get_internal_alias_rspamd_setting(self, use_cache=True) -> Optional[RspamdSettings]:
"""Gets the Rspamd setting (if it exists) that disallows external domains
def get_internal_alias_rspamd_settings(
self, use_cache=True
) -> Tuple[Optional[RspamdSettings], Optional[RspamdSettings]]:
"""Gets the Rspamd settings (if it exists) that disallows external domains
to send emails to a specific set of email addresses. Squire recognises
which Rspamd setting to find based on the setting's name.
See `self.INTERNAL_ALIAS_SETTING_NAME`
"""
if self._internal_rspamd_setting is not None and use_cache:
return self._internal_rspamd_setting
if (
self._internal_rspamd_setting_whitelist is not None
and self._internal_rspamd_setting_blacklist is not None
and use_cache
):
return self._internal_rspamd_setting_whitelist, self._internal_rspamd_setting_blacklist

self._internal_rspamd_setting_whitelist = None
self._internal_rspamd_setting_blacklist = None

# Fetch all Rspamd settings
settings = self._client.get_rspamd_setting_all()
for setting in settings:
# Setting description matches the one we normally set
if setting.desc == self.INTERNAL_ALIAS_SETTING_NAME:
self._internal_rspamd_setting = setting
return setting
return None
if setting.desc == self.INTERNAL_ALIAS_SETTING_NAME % self.INTERNAL_ALIAS_SETTING_WHITELIST_NAME:
self._internal_rspamd_setting_whitelist = setting
elif setting.desc == self.INTERNAL_ALIAS_SETTING_NAME % self.INTERNAL_ALIAS_SETTING_BLACKLIST_NAME:
self._internal_rspamd_setting_blacklist = setting
return (self._internal_rspamd_setting_whitelist, self._internal_rspamd_setting_blacklist)

def is_address_internal(self, address: str) -> bool:
"""Whether an alias address is made internal by means of an Rspamd setting"""
setting = self.get_internal_alias_rspamd_setting()
if setting is None or not setting.active:
setting_w, setting_b = self.get_internal_alias_rspamd_settings()
if setting_w is None or not setting_w.active or setting_b is None or not setting_b.active:
return False

prefix = re.escape('rcpt = "/^(')
suffix = re.escape(')$/"')
wildcard = '[^"\n]*'
# Double escape since we're using regex to find a match ourselves
address = re.escape(re.escape(address))
mtch = re.search(f"{prefix}{wildcard}{address}{wildcard}{suffix}", setting.content)
return mtch is not None
mtch_w = re.search(f"{prefix}{wildcard}{address}{wildcard}{suffix}", setting_w.content)
mtch_b = re.search(f"{prefix}{wildcard}{address}{wildcard}{suffix}", setting_b.content)
return mtch_w is not None and mtch_b is not None

def update_internal_addresses(self) -> None:
"""Makes a list of member aliases 'internal'. That is, these aliases can only
be emailed from within one of the domains set up in Mailcow.
See `templates/internal_mailbox.conf` for the Rspamd configuration used to
achieve this.
Example:
`@example.com` is a domain set up in Mailcow.
Using this function to make `members@example.com` interal ensures that
`foo@spam.com` cannot send emails to `members@example.com` (those are
discarded), while `importantperson@example.com` can send emails to
`members@example.com`. Spoofed sender addresses are properly discarded
as well.
"""
# Escape addresses
addresses = self.INTERNAL_ALIAS_ADDRESSES
addresses = list(map(lambda addr: re.escape(addr), addresses))

# Fetch existing rspamd settings
setting = self.get_internal_alias_rspamd_setting(use_cache=False)
def update_internal_alias_setting(self, addresses: List[str], setting: RspamdSettings, is_whitelist_setting: bool):
"""Updates the allow/block setting"""
if setting is not None and setting.active and f'rcpt = "/^({"|".join(addresses)})$/"' in setting.content:
# Setting already exists, is active, and is up-to-date; no need to do anything
return

# Setting emails are different than from what we expect, or the setting
# does not yet exist
template = get_template("mailcow_integration/internal_mailbox.conf")
subtemplate_name = "allow" if is_whitelist_setting else "block"
subsetting_name = (
self.INTERNAL_ALIAS_SETTING_WHITELIST_NAME
if is_whitelist_setting
else self.INTERNAL_ALIAS_SETTING_BLACKLIST_NAME
)

template = get_template("mailcow_integration/internal_mailbox_%s.conf" % subtemplate_name)
setting_content = template.render({"addresses": addresses})
id = setting.id if setting is not None else None
setting = RspamdSettings(id, self.INTERNAL_ALIAS_SETTING_NAME, setting_content, True)
setting = RspamdSettings(id, self.INTERNAL_ALIAS_SETTING_NAME % subsetting_name, setting_content, True)

if setting.id is None:
# Setting does not yet exist
Expand All @@ -177,6 +180,28 @@ def update_internal_addresses(self) -> None:
# Setting exists but should be updated
self._client.update_rspamd_setting(setting)

def update_internal_addresses(self) -> None:
"""Makes specific member aliases 'internal'. That is, these aliases can only
be emailed from within one of the domains set up in Mailcow.
See `templates/internal_mailbox_<allow/block>.conf` for the Rspamd configuration used to
achieve this.
Example:
`@example.com` is a domain set up in Mailcow
members@example.com is an internal member address according to Squire's mailcowconfig.json
Using this function ensures that `foo@spam.com` (note the domain) cannot send emails to
`members@example.com` (those are rejected), while `importantperson@example.com` (note the domain)
can send emails to `members@example.com`.
Spoofed sender addresses are properly discarded as well.
"""
# Escape addresses
addresses = self.INTERNAL_ALIAS_ADDRESSES
addresses = list(map(lambda addr: re.escape(addr), addresses))

setting_w, setting_b = self.get_internal_alias_rspamd_settings(use_cache=False)
self.update_internal_alias_setting(addresses, setting_w, True)
self.update_internal_alias_setting(addresses, setting_b, False)

def get_alias_all(self, use_cache=True) -> List[MailcowAlias]:
"""Gets all email aliases"""
if use_cache and self._alias_cache is not None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ <h1>Mailcow Server Status</h1>

<div class="d-flex justify-content-between align-items-center">
<h2>Member Aliases</h2>
<button type="submit" class="btn btn-info" data-toggle="modal" data-target="#rspamdModal"><i class="fas fa-pencil-ruler"></i> Rspamd Rule</button>
<button type="submit" class="btn btn-info" data-toggle="modal" data-target="#rspamdModal" {% if error %}disabled{%endif%}><i class="fas fa-pencil-ruler"></i> Rspamd Rule</button>
{% if error %}
<button type="button" class="btn btn-primary" disabled><i class="fas fa-redo-alt"></i> Update</button>
{% else %}
Expand All @@ -51,34 +51,47 @@ <h2>Member Aliases</h2>
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="rspamdModalLabel">
{% if internal_alias_rspamd_setting %}
{{internal_alias_rspamd_setting.desc}} ({{internal_alias_rspamd_setting.id}})
{% else %}
Rspamd Setting - Internal Alias
{% endif %}
Rspamd Settings - Internal Alias
</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
{% if not internal_alias_rspamd_setting %}
<div class="alert alert-danger d-flex align-items-center" role="alert">
<i class="fas fa-question-circle mr-3"></i> Setting is missing.
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item">
<a class="nav-link active" data-toggle="tab" href="#rspamd-internal-allow" role="tab" aria-controls="rspamd-internal-block" aria-selected="true">
{% if not internal_alias_rspamd_setting_allow %}
<i class="fas fa-question-circle"></i>
{% elif not internal_alias_rspamd_setting_allow.active %}
<i class="fas fa-pause-circle"></i>
{% else %}
<i class="fas fa-check-circle"></i>
{% endif %}
Allow Rule
</a>
</li>
<li class="nav-item">
<a class="nav-link" data-toggle="tab" href="#rspamd-internal-block" role="tab" aria-controls="rspamd-internal-allow" aria-selected="false">
{% if not internal_alias_rspamd_setting_block %}
<i class="fas fa-question-circle"></i>
{% elif not internal_alias_rspamd_setting_block.active %}
<i class="fas fa-pause-circle"></i>
{% else %}
<i class="fas fa-check-circle"></i>
{% endif %}
Reject Rule
</a>
</li>
</ul>
<div class="tab-content" id="myTabContent">
<div class="tab-pane fade show active" id="rspamd-internal-allow" role="tabpanel" aria-labelledby="home-tab">
{% include "mailcow_integration/snippets/rspamd_setting.html" with rspamd_setting=internal_alias_rspamd_setting_allow %}
</div>
{% else %}
{% if not internal_alias_rspamd_setting.active %}
<div class="alert alert-danger d-flex align-items-center" role="alert">
<i class="fas fa-times-circle mr-3"></i> Setting is inactive.
</div>
{% endif %}

<p>
The following Rspamd setting ensures only addresses in domains set up in Mailcow can send emails to the 'internal' addresses.
</p>

<pre>{{internal_alias_rspamd_setting.content }}</pre>
{% endif %}
<div class="tab-pane fade" id="rspamd-internal-block" role="tabpanel" aria-labelledby="profile-tab">
{% include "mailcow_integration/snippets/rspamd_setting.html" with rspamd_setting=internal_alias_rspamd_setting_block %}
</div>
</div>
</div>
<div class="modal-footer">
<div class="d-flex justify-content-end" style="gap: 4px;">
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# MANAGED BY SQUIRE - DO NOT MODIFY
# Last updated on {% now "Y-m-d H:i:s.u" %}
# This rule matches any internal address
# If this rule matches, NO FURTHER RULES ARE CHECKED
priority = 11;
rcpt = "/^({{ addresses|join:'|' }})$/";
authenticated = yes;
apply "default" {
INTERNAL_MAILBOX_TO_ALIAS = -9999.0;
}
symbols [
"INTERNAL_MAILBOX_TO_ALIAS"
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# MANAGED BY SQUIRE - DO NOT MODIFY
# Last updated on {% now "Y-m-d H:i:s.u" %}
# This rule blocks all emails sent to this address.
# Note how this rule has a lower priority than the allow-rule
priority = 10;
rcpt = "/^({{ addresses|join:'|' }})$/";

apply "default" {
EXTERNAL_MAILBOX_TO_ALIAS = 9999.0;
}
symbols [
"EXTERNAL_MAILBOX_TO_ALIAS"
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<p></p>
{% if not rspamd_setting %}
<div class="alert alert-danger d-flex align-items-center" role="alert">
<i class="fas fa-question-circle mr-3"></i> Setting is missing.
</div>
{% else %}
{% if not rspamd_setting.active %}
<div class="alert alert-danger d-flex align-items-center" role="alert">
<i class="fas fa-pause-circle mr-3"></i> Setting is inactive.
</div>
{% endif %}
<h5>{{rspamd_setting.desc}} &ndash; {{rspamd_setting.id}}</h5>
<pre><code>{{rspamd_setting.content }}</code></pre>
{% endif %}
4 changes: 2 additions & 2 deletions mailcow_integration/tests/api/test_interfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ def get_alias_json():
"in_primary_domain": "",
"id": 42,
"domain": "example.com",
"public_comment": "[MANAGED BY SQUIRE] Members Alias",
"public_comment": "[DEV][MANAGED BY SQUIRE] Members Alias",
"private_comment": None,
"goto": "bar@example.com,baz@example.com",
"address": "foo@example.com",
Expand Down Expand Up @@ -83,7 +83,7 @@ def get_rspamd_json():
return deepcopy(
{
"id": 1,
"desc": "[MANAGED BY SQUIRE] Internal Alias",
"desc": "[DEV][MANAGED BY SQUIRE] Internal Alias",
"content": "# MANAGED BY SQUIRE - DO NOT MODIFY;\r\nfoo faa rules;\r\nyet another rule;\r\n]",
"active": 0,
}
Expand Down
Loading