mirror of
https://github.com/chatmail/relay.git
synced 2026-05-11 16:34:39 +00:00
Compare commits
55 Commits
mtail
...
docker-reb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
645b60d293 | ||
|
|
f939c307f6 | ||
|
|
ae0b2345de | ||
|
|
e5ba9f9d03 | ||
|
|
e20256c484 | ||
|
|
1889f554a3 | ||
|
|
f26cb08500 | ||
|
|
60ff9821b1 | ||
|
|
f9fad1fd03 | ||
|
|
8be7082d21 | ||
|
|
6e5004dc9f | ||
|
|
92b6825b5b | ||
|
|
8bba78ebaf | ||
|
|
615613bd66 | ||
|
|
c5a8d00558 | ||
|
|
38fb191c86 | ||
|
|
dbc386bd00 | ||
|
|
1e617041bd | ||
|
|
959afe6f14 | ||
|
|
c605d1a465 | ||
|
|
72ae869eab | ||
|
|
e1be8a24a1 | ||
|
|
3896071921 | ||
|
|
0d5e544291 | ||
|
|
31fc856993 | ||
|
|
fb798bb6a3 | ||
|
|
985e98ccb7 | ||
|
|
91df11015e | ||
|
|
d4f8a29243 | ||
|
|
0144fc3ea8 | ||
|
|
e7ce6679b9 | ||
|
|
d1adf52f89 | ||
|
|
56d0e2ca27 | ||
|
|
2613558db6 | ||
|
|
6843fcb1a0 | ||
|
|
ff54ad88d8 | ||
|
|
cce2b27ae7 | ||
|
|
87022e3681 | ||
|
|
06560dd071 | ||
|
|
1b0337a5f7 | ||
|
|
dfcaf415b1 | ||
|
|
c0718325ef | ||
|
|
7d72b0e592 | ||
|
|
8f1e23d98e | ||
|
|
56aaf2649b | ||
|
|
2660b4d24c | ||
|
|
ea60ecfb57 | ||
|
|
2a3a224cc2 | ||
|
|
e42139e97b | ||
|
|
65b660c413 | ||
|
|
dd2beb226a | ||
|
|
9c7508cc33 | ||
|
|
ab3492d9a1 | ||
|
|
032faf0a94 | ||
|
|
c45fe03652 |
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.git
|
||||||
|
data/
|
||||||
|
venv/
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.orig
|
||||||
|
.pytest_cache
|
||||||
3
.github/workflows/ci.yaml
vendored
3
.github/workflows/ci.yaml
vendored
@@ -14,7 +14,8 @@ jobs:
|
|||||||
# Otherwise `test_deployed_state` will be unhappy.
|
# Otherwise `test_deployed_state` will be unhappy.
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
- name: download filtermail
|
||||||
|
run: curl -L https://github.com/chatmail/filtermail/releases/download/v0.3.0/filtermail-x86_64 -o /usr/local/bin/filtermail && chmod +x /usr/local/bin/filtermail
|
||||||
- name: run chatmaild tests
|
- name: run chatmaild tests
|
||||||
working-directory: chatmaild
|
working-directory: chatmaild
|
||||||
run: pipx run tox
|
run: pipx run tox
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -164,3 +164,8 @@ cython_debug/
|
|||||||
#.idea/
|
#.idea/
|
||||||
|
|
||||||
chatmail.zone
|
chatmail.zone
|
||||||
|
|
||||||
|
# docker
|
||||||
|
/data/
|
||||||
|
/custom/
|
||||||
|
.env
|
||||||
|
|||||||
@@ -121,6 +121,13 @@
|
|||||||
Provide an "fsreport" CLI for more fine grained analysis of message files.
|
Provide an "fsreport" CLI for more fine grained analysis of message files.
|
||||||
([#637](https://github.com/chatmail/relay/pull/637))
|
([#637](https://github.com/chatmail/relay/pull/637))
|
||||||
|
|
||||||
|
- Add installation via docker compose (MVP 1). The instructions, known issues and limitations are located in `/docs`
|
||||||
|
([#614](https://github.com/chatmail/relay/pull/614))
|
||||||
|
|
||||||
|
- Add configuration parameters
|
||||||
|
([#614](https://github.com/chatmail/relay/pull/614)):
|
||||||
|
- `change_kernel_settings` - Whether to change kernel parameters during installation (default: `True`)
|
||||||
|
- `fs_inotify_max_user_instances_and_watchers` - Value for kernel parameters `fs.inotify.max_user_instances` and `fs.inotify.max_user_watches` (default: `65535`)
|
||||||
|
|
||||||
## 1.7.0 2025-09-11
|
## 1.7.0 2025-09-11
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ where = ['src']
|
|||||||
[project.scripts]
|
[project.scripts]
|
||||||
doveauth = "chatmaild.doveauth:main"
|
doveauth = "chatmaild.doveauth:main"
|
||||||
chatmail-metadata = "chatmaild.metadata:main"
|
chatmail-metadata = "chatmaild.metadata:main"
|
||||||
filtermail = "chatmaild.filtermail:main"
|
|
||||||
chatmail-metrics = "chatmaild.metrics:main"
|
chatmail-metrics = "chatmaild.metrics:main"
|
||||||
chatmail-expire = "chatmaild.expire:main"
|
chatmail-expire = "chatmaild.expire:main"
|
||||||
chatmail-fsreport = "chatmaild.fsreport:main"
|
chatmail-fsreport = "chatmaild.fsreport:main"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import iniconfig
|
import iniconfig
|
||||||
@@ -20,7 +21,8 @@ class Config:
|
|||||||
def __init__(self, inipath, params):
|
def __init__(self, inipath, params):
|
||||||
self._inipath = inipath
|
self._inipath = inipath
|
||||||
self.mail_domain = params["mail_domain"]
|
self.mail_domain = params["mail_domain"]
|
||||||
self.max_user_send_per_minute = int(params["max_user_send_per_minute"])
|
self.max_user_send_per_minute = int(params.get("max_user_send_per_minute", 60))
|
||||||
|
self.max_user_send_burst_size = int(params.get("max_user_send_burst_size", 10))
|
||||||
self.max_mailbox_size = params["max_mailbox_size"]
|
self.max_mailbox_size = params["max_mailbox_size"]
|
||||||
self.max_message_size = int(params.get("max_message_size", "31457280"))
|
self.max_message_size = int(params.get("max_message_size", "31457280"))
|
||||||
self.delete_mails_after = params["delete_mails_after"]
|
self.delete_mails_after = params["delete_mails_after"]
|
||||||
@@ -32,17 +34,25 @@ class Config:
|
|||||||
self.passthrough_senders = params["passthrough_senders"].split()
|
self.passthrough_senders = params["passthrough_senders"].split()
|
||||||
self.passthrough_recipients = params["passthrough_recipients"].split()
|
self.passthrough_recipients = params["passthrough_recipients"].split()
|
||||||
self.www_folder = params.get("www_folder", "")
|
self.www_folder = params.get("www_folder", "")
|
||||||
self.filtermail_smtp_port = int(params["filtermail_smtp_port"])
|
self.filtermail_smtp_port = int(params.get("filtermail_smtp_port", "10080"))
|
||||||
self.filtermail_smtp_port_incoming = int(
|
self.filtermail_smtp_port_incoming = int(
|
||||||
params["filtermail_smtp_port_incoming"]
|
params.get("filtermail_smtp_port_incoming", "10081")
|
||||||
)
|
)
|
||||||
self.postfix_reinject_port = int(params["postfix_reinject_port"])
|
self.postfix_reinject_port = int(params.get("postfix_reinject_port", "10025"))
|
||||||
self.postfix_reinject_port_incoming = int(
|
self.postfix_reinject_port_incoming = int(
|
||||||
params["postfix_reinject_port_incoming"]
|
params.get("postfix_reinject_port_incoming", "10026")
|
||||||
)
|
)
|
||||||
self.mtail_address = params.get("mtail_address")
|
self.mtail_address = params.get("mtail_address")
|
||||||
self.disable_ipv6 = params.get("disable_ipv6", "false").lower() == "true"
|
self.disable_ipv6 = params.get("disable_ipv6", "false").lower() == "true"
|
||||||
|
self.addr_v4 = os.environ.get("CHATMAIL_ADDR_V4", "")
|
||||||
|
self.addr_v6 = os.environ.get("CHATMAIL_ADDR_V6", "")
|
||||||
self.acme_email = params.get("acme_email", "")
|
self.acme_email = params.get("acme_email", "")
|
||||||
|
self.change_kernel_settings = (
|
||||||
|
params.get("change_kernel_settings", "true").lower() == "true"
|
||||||
|
)
|
||||||
|
self.fs_inotify_max_user_instances_and_watchers = int(
|
||||||
|
params["fs_inotify_max_user_instances_and_watchers"]
|
||||||
|
)
|
||||||
self.imap_rawlog = params.get("imap_rawlog", "false").lower() == "true"
|
self.imap_rawlog = params.get("imap_rawlog", "false").lower() == "true"
|
||||||
self.imap_compress = params.get("imap_compress", "false").lower() == "true"
|
self.imap_compress = params.get("imap_compress", "false").lower() == "true"
|
||||||
if "iroh_relay" not in params:
|
if "iroh_relay" not in params:
|
||||||
|
|||||||
@@ -1,378 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
import asyncio
|
|
||||||
import base64
|
|
||||||
import binascii
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from email import policy
|
|
||||||
from email.parser import BytesParser
|
|
||||||
from email.utils import parseaddr
|
|
||||||
from smtplib import SMTP as SMTPClient
|
|
||||||
|
|
||||||
from aiosmtpd.controller import Controller
|
|
||||||
from aiosmtpd.smtp import SMTP
|
|
||||||
|
|
||||||
from .config import read_config
|
|
||||||
|
|
||||||
ENCRYPTION_NEEDED_523 = "523 Encryption Needed: Invalid Unencrypted Mail"
|
|
||||||
|
|
||||||
|
|
||||||
def check_openpgp_payload(payload: bytes):
|
|
||||||
"""Checks the OpenPGP payload.
|
|
||||||
|
|
||||||
OpenPGP payload must consist only of PKESK and SKESK packets
|
|
||||||
terminated by a single SEIPD packet.
|
|
||||||
|
|
||||||
Returns True if OpenPGP payload is correct,
|
|
||||||
False otherwise.
|
|
||||||
|
|
||||||
May raise IndexError while trying to read OpenPGP packet header
|
|
||||||
if it is truncated.
|
|
||||||
"""
|
|
||||||
i = 0
|
|
||||||
while i < len(payload):
|
|
||||||
# Only OpenPGP format is allowed.
|
|
||||||
if payload[i] & 0xC0 != 0xC0:
|
|
||||||
return False
|
|
||||||
|
|
||||||
packet_type_id = payload[i] & 0x3F
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
while payload[i] >= 224 and payload[i] < 255:
|
|
||||||
# Partial body length.
|
|
||||||
partial_length = 1 << (payload[i] & 0x1F)
|
|
||||||
i += 1 + partial_length
|
|
||||||
|
|
||||||
if payload[i] < 192:
|
|
||||||
# One-octet length.
|
|
||||||
body_len = payload[i]
|
|
||||||
i += 1
|
|
||||||
elif payload[i] < 224:
|
|
||||||
# Two-octet length.
|
|
||||||
body_len = ((payload[i] - 192) << 8) + payload[i + 1] + 192
|
|
||||||
i += 2
|
|
||||||
elif payload[i] == 255:
|
|
||||||
# Five-octet length.
|
|
||||||
body_len = (
|
|
||||||
(payload[i + 1] << 24)
|
|
||||||
| (payload[i + 2] << 16)
|
|
||||||
| (payload[i + 3] << 8)
|
|
||||||
| payload[i + 4]
|
|
||||||
)
|
|
||||||
i += 5
|
|
||||||
else:
|
|
||||||
# Impossible, partial body length was processed above.
|
|
||||||
return False
|
|
||||||
|
|
||||||
i += body_len
|
|
||||||
|
|
||||||
if i == len(payload):
|
|
||||||
# Last packet should be
|
|
||||||
# Symmetrically Encrypted and Integrity Protected Data Packet (SEIPD)
|
|
||||||
#
|
|
||||||
# This is the only place where this function may return `True`.
|
|
||||||
return packet_type_id == 18
|
|
||||||
elif packet_type_id not in [1, 3]:
|
|
||||||
# All packets except the last one must be either
|
|
||||||
# Public-Key Encrypted Session Key Packet (PKESK)
|
|
||||||
# or
|
|
||||||
# Symmetric-Key Encrypted Session Key Packet (SKESK)
|
|
||||||
return False
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def check_armored_payload(payload: str, outgoing: bool):
|
|
||||||
"""Check the armored PGP message for invalid content.
|
|
||||||
|
|
||||||
:param payload: the armored PGP message
|
|
||||||
:param outgoing: whether the message is outgoing or incoming
|
|
||||||
:return: whether the message is a valid PGP message
|
|
||||||
"""
|
|
||||||
prefix = "-----BEGIN PGP MESSAGE-----\r\n"
|
|
||||||
if not payload.startswith(prefix):
|
|
||||||
return False
|
|
||||||
payload = payload.removeprefix(prefix)
|
|
||||||
|
|
||||||
while payload.endswith("\r\n"):
|
|
||||||
payload = payload.removesuffix("\r\n")
|
|
||||||
suffix = "-----END PGP MESSAGE-----"
|
|
||||||
if not payload.endswith(suffix):
|
|
||||||
return False
|
|
||||||
payload = payload.removesuffix(suffix)
|
|
||||||
|
|
||||||
version_comment = "Version: "
|
|
||||||
if payload.startswith(version_comment):
|
|
||||||
if outgoing: # Disallow comments in outgoing messages
|
|
||||||
return False
|
|
||||||
# Remove comments from incoming messages
|
|
||||||
payload = payload.partition("\r\n")[2]
|
|
||||||
|
|
||||||
while payload.startswith("\r\n"):
|
|
||||||
payload = payload.removeprefix("\r\n")
|
|
||||||
|
|
||||||
# Remove CRC24.
|
|
||||||
payload = payload.rpartition("=")[0]
|
|
||||||
|
|
||||||
try:
|
|
||||||
payload = base64.b64decode(payload)
|
|
||||||
except binascii.Error:
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
return check_openpgp_payload(payload)
|
|
||||||
except IndexError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def is_securejoin(message):
|
|
||||||
if message.get("secure-join") not in ["vc-request", "vg-request"]:
|
|
||||||
return False
|
|
||||||
if not message.is_multipart():
|
|
||||||
return False
|
|
||||||
parts_count = 0
|
|
||||||
for part in message.iter_parts():
|
|
||||||
parts_count += 1
|
|
||||||
if parts_count > 1:
|
|
||||||
return False
|
|
||||||
if part.is_multipart():
|
|
||||||
return False
|
|
||||||
if part.get_content_type() != "text/plain":
|
|
||||||
return False
|
|
||||||
|
|
||||||
payload = part.get_payload().strip().lower()
|
|
||||||
if payload not in ("secure-join: vc-request", "secure-join: vg-request"):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def check_encrypted(message, outgoing=True):
|
|
||||||
"""Check that the message is an OpenPGP-encrypted message.
|
|
||||||
|
|
||||||
MIME structure of the message must correspond to <https://www.rfc-editor.org/rfc/rfc3156>.
|
|
||||||
"""
|
|
||||||
if not message.is_multipart():
|
|
||||||
return False
|
|
||||||
if message.get_content_type() != "multipart/encrypted":
|
|
||||||
return False
|
|
||||||
parts_count = 0
|
|
||||||
for part in message.iter_parts():
|
|
||||||
# We explicitly check Content-Type of each part later,
|
|
||||||
# but this is to be absolutely sure `get_payload()` returns string and not list.
|
|
||||||
if part.is_multipart():
|
|
||||||
return False
|
|
||||||
|
|
||||||
if parts_count == 0:
|
|
||||||
if part.get_content_type() != "application/pgp-encrypted":
|
|
||||||
return False
|
|
||||||
|
|
||||||
payload = part.get_payload()
|
|
||||||
if payload.strip() != "Version: 1":
|
|
||||||
return False
|
|
||||||
elif parts_count == 1:
|
|
||||||
if part.get_content_type() != "application/octet-stream":
|
|
||||||
return False
|
|
||||||
|
|
||||||
if not check_armored_payload(part.get_payload(), outgoing=outgoing):
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
parts_count += 1
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def asyncmain_beforequeue(config, mode):
|
|
||||||
if mode == "outgoing":
|
|
||||||
port = config.filtermail_smtp_port
|
|
||||||
handler = OutgoingBeforeQueueHandler(config)
|
|
||||||
else:
|
|
||||||
port = config.filtermail_smtp_port_incoming
|
|
||||||
handler = IncomingBeforeQueueHandler(config)
|
|
||||||
HackedController(
|
|
||||||
handler,
|
|
||||||
hostname="127.0.0.1",
|
|
||||||
port=port,
|
|
||||||
data_size_limit=config.max_message_size,
|
|
||||||
).start()
|
|
||||||
|
|
||||||
|
|
||||||
def recipient_matches_passthrough(recipient, passthrough_recipients):
|
|
||||||
for addr in passthrough_recipients:
|
|
||||||
if recipient == addr:
|
|
||||||
return True
|
|
||||||
if addr[0] == "@" and recipient.endswith(addr):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
class HackedController(Controller):
|
|
||||||
def factory(self):
|
|
||||||
return SMTPDiscardRCPTO_options(self.handler, **self.SMTP_kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class SMTPDiscardRCPTO_options(SMTP):
|
|
||||||
def _getparams(self, params):
|
|
||||||
# Ignore RCPT TO parameters.
|
|
||||||
#
|
|
||||||
# Otherwise parameters such as `ORCPT=...`
|
|
||||||
# or `NOTIFY=DELAY,FAILURE` (generated by Stalwart)
|
|
||||||
# make aiosmtpd reject the message here:
|
|
||||||
# <https://github.com/aio-libs/aiosmtpd/blob/98f578389ae86e5345cc343fa4e5a17b21d9c96d/aiosmtpd/smtp.py#L1379-L1384>
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
class OutgoingBeforeQueueHandler:
|
|
||||||
def __init__(self, config):
|
|
||||||
self.config = config
|
|
||||||
self.send_rate_limiter = SendRateLimiter()
|
|
||||||
|
|
||||||
async def handle_MAIL(self, server, session, envelope, address, mail_options):
|
|
||||||
log_info(f"handle_MAIL from {address}")
|
|
||||||
envelope.mail_from = address
|
|
||||||
max_sent = self.config.max_user_send_per_minute
|
|
||||||
if not self.send_rate_limiter.is_sending_allowed(address, max_sent):
|
|
||||||
return f"450 4.7.1: Too much mail from {address}"
|
|
||||||
|
|
||||||
parts = envelope.mail_from.split("@")
|
|
||||||
if len(parts) != 2:
|
|
||||||
return f"500 Invalid from address <{envelope.mail_from!r}>"
|
|
||||||
|
|
||||||
return "250 OK"
|
|
||||||
|
|
||||||
async def handle_DATA(self, server, session, envelope):
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
return await loop.run_in_executor(None, self.sync_handle_DATA, envelope)
|
|
||||||
|
|
||||||
def sync_handle_DATA(self, envelope):
|
|
||||||
log_info("handle_DATA before-queue")
|
|
||||||
error = self.check_DATA(envelope)
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
log_info("re-injecting the mail that passed checks")
|
|
||||||
client = SMTPClient("localhost", self.config.postfix_reinject_port)
|
|
||||||
client.sendmail(
|
|
||||||
envelope.mail_from, envelope.rcpt_tos, envelope.original_content
|
|
||||||
)
|
|
||||||
return "250 OK"
|
|
||||||
|
|
||||||
def check_DATA(self, envelope):
|
|
||||||
"""the central filtering function for e-mails."""
|
|
||||||
log_info(f"Processing DATA message from {envelope.mail_from}")
|
|
||||||
|
|
||||||
message = BytesParser(policy=policy.default).parsebytes(envelope.content)
|
|
||||||
mail_encrypted = check_encrypted(message, outgoing=True)
|
|
||||||
|
|
||||||
_, from_addr = parseaddr(message.get("from").strip())
|
|
||||||
|
|
||||||
if envelope.mail_from.lower() != from_addr.lower():
|
|
||||||
return f"500 Invalid FROM <{from_addr!r}> for <{envelope.mail_from!r}>"
|
|
||||||
|
|
||||||
if mail_encrypted or is_securejoin(message):
|
|
||||||
print("Outgoing: Filtering encrypted mail.", file=sys.stderr)
|
|
||||||
return
|
|
||||||
|
|
||||||
print("Outgoing: Filtering unencrypted mail.", file=sys.stderr)
|
|
||||||
|
|
||||||
if envelope.mail_from in self.config.passthrough_senders:
|
|
||||||
return
|
|
||||||
|
|
||||||
# allow self-sent Autocrypt Setup Message
|
|
||||||
if envelope.rcpt_tos == [from_addr]:
|
|
||||||
if message.get("subject") == "Autocrypt Setup Message":
|
|
||||||
if message.get_content_type() == "multipart/mixed":
|
|
||||||
return
|
|
||||||
|
|
||||||
passthrough_recipients = self.config.passthrough_recipients
|
|
||||||
|
|
||||||
for recipient in envelope.rcpt_tos:
|
|
||||||
if recipient_matches_passthrough(recipient, passthrough_recipients):
|
|
||||||
continue
|
|
||||||
|
|
||||||
print("Rejected unencrypted mail.", file=sys.stderr)
|
|
||||||
return ENCRYPTION_NEEDED_523
|
|
||||||
|
|
||||||
|
|
||||||
class IncomingBeforeQueueHandler:
|
|
||||||
def __init__(self, config):
|
|
||||||
self.config = config
|
|
||||||
|
|
||||||
async def handle_DATA(self, server, session, envelope):
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
return await loop.run_in_executor(None, self.sync_handle_DATA, envelope)
|
|
||||||
|
|
||||||
def sync_handle_DATA(self, envelope):
|
|
||||||
log_info("handle_DATA before-queue")
|
|
||||||
error = self.check_DATA(envelope)
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
log_info("re-injecting the mail that passed checks")
|
|
||||||
|
|
||||||
client = SMTPClient(
|
|
||||||
"localhost",
|
|
||||||
self.config.postfix_reinject_port_incoming,
|
|
||||||
)
|
|
||||||
client.sendmail(
|
|
||||||
envelope.mail_from, envelope.rcpt_tos, envelope.original_content
|
|
||||||
)
|
|
||||||
return "250 OK"
|
|
||||||
|
|
||||||
def check_DATA(self, envelope):
|
|
||||||
"""the central filtering function for e-mails."""
|
|
||||||
log_info(f"Processing DATA message from {envelope.mail_from}")
|
|
||||||
|
|
||||||
message = BytesParser(policy=policy.default).parsebytes(envelope.content)
|
|
||||||
mail_encrypted = check_encrypted(message, outgoing=False)
|
|
||||||
|
|
||||||
if mail_encrypted or is_securejoin(message):
|
|
||||||
print("Incoming: Filtering encrypted mail.", file=sys.stderr)
|
|
||||||
return
|
|
||||||
|
|
||||||
print("Incoming: Filtering unencrypted mail.", file=sys.stderr)
|
|
||||||
|
|
||||||
# we want cleartext mailer-daemon messages to pass through
|
|
||||||
# chatmail core will typically not display them as normal messages
|
|
||||||
if message.get("auto-submitted"):
|
|
||||||
_, from_addr = parseaddr(message.get("from").strip())
|
|
||||||
if from_addr.lower().startswith("mailer-daemon@"):
|
|
||||||
if message.get_content_type() == "multipart/report":
|
|
||||||
return
|
|
||||||
|
|
||||||
for recipient in envelope.rcpt_tos:
|
|
||||||
user = self.config.get_user(recipient)
|
|
||||||
if user is None or user.is_incoming_cleartext_ok():
|
|
||||||
continue
|
|
||||||
|
|
||||||
print("Rejected unencrypted mail.", file=sys.stderr)
|
|
||||||
return ENCRYPTION_NEEDED_523
|
|
||||||
|
|
||||||
|
|
||||||
class SendRateLimiter:
|
|
||||||
def __init__(self):
|
|
||||||
self.addr2timestamps = {}
|
|
||||||
|
|
||||||
def is_sending_allowed(self, mail_from, max_send_per_minute):
|
|
||||||
last = self.addr2timestamps.setdefault(mail_from, [])
|
|
||||||
now = time.time()
|
|
||||||
last[:] = [ts for ts in last if ts >= (now - 60)]
|
|
||||||
if len(last) <= max_send_per_minute:
|
|
||||||
last.append(now)
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def log_info(msg):
|
|
||||||
print(msg, file=sys.stderr)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
args = sys.argv[1:]
|
|
||||||
assert len(args) == 2
|
|
||||||
config = read_config(args[0])
|
|
||||||
mode = args[1]
|
|
||||||
loop = asyncio.new_event_loop()
|
|
||||||
asyncio.set_event_loop(loop)
|
|
||||||
assert mode in ["incoming", "outgoing"]
|
|
||||||
task = asyncmain_beforequeue(config, mode)
|
|
||||||
loop.create_task(task)
|
|
||||||
log_info("entering serving loop")
|
|
||||||
loop.run_forever()
|
|
||||||
@@ -11,9 +11,12 @@ mail_domain = {mail_domain}
|
|||||||
# Restrictions on user addresses
|
# Restrictions on user addresses
|
||||||
#
|
#
|
||||||
|
|
||||||
# how many mails a user can send out per minute
|
# email sending rate per user and minute
|
||||||
max_user_send_per_minute = 60
|
max_user_send_per_minute = 60
|
||||||
|
|
||||||
|
# per-user max burst size for sending rate limiting (GCRA bucket capacity)
|
||||||
|
max_user_send_burst_size = 10
|
||||||
|
|
||||||
# maximum mailbox size of a chatmail address
|
# maximum mailbox size of a chatmail address
|
||||||
max_mailbox_size = 500M
|
max_mailbox_size = 500M
|
||||||
|
|
||||||
@@ -66,6 +69,16 @@ disable_ipv6 = False
|
|||||||
# Your email adress, which will be used in acmetool to manage Let's Encrypt SSL certificates
|
# Your email adress, which will be used in acmetool to manage Let's Encrypt SSL certificates
|
||||||
acme_email =
|
acme_email =
|
||||||
|
|
||||||
|
#
|
||||||
|
# Kernel settings
|
||||||
|
#
|
||||||
|
|
||||||
|
# if you set "True", the kernel settings will be configured according to the values below
|
||||||
|
change_kernel_settings = True
|
||||||
|
|
||||||
|
# change fs.inotify.max_user_instances and fs.inotify.max_user_watches kernel settings
|
||||||
|
fs_inotify_max_user_instances_and_watchers = 65535
|
||||||
|
|
||||||
# Defaults to https://iroh.{{mail_domain}} and running `iroh-relay` on the chatmail
|
# Defaults to https://iroh.{{mail_domain}} and running `iroh-relay` on the chatmail
|
||||||
# service.
|
# service.
|
||||||
# If you set it to anything else, the service will be disabled
|
# If you set it to anything else, the service will be disabled
|
||||||
|
|||||||
@@ -1,361 +0,0 @@
|
|||||||
import pytest
|
|
||||||
|
|
||||||
from chatmaild.filtermail import (
|
|
||||||
IncomingBeforeQueueHandler,
|
|
||||||
OutgoingBeforeQueueHandler,
|
|
||||||
SendRateLimiter,
|
|
||||||
check_armored_payload,
|
|
||||||
check_encrypted,
|
|
||||||
is_securejoin,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def maildomain():
|
|
||||||
# let's not depend on a real chatmail instance for the offline tests below
|
|
||||||
return "chatmail.example.org"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def handler(make_config, maildomain):
|
|
||||||
config = make_config(maildomain)
|
|
||||||
return OutgoingBeforeQueueHandler(config)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def inhandler(make_config, maildomain):
|
|
||||||
config = make_config(maildomain)
|
|
||||||
return IncomingBeforeQueueHandler(config)
|
|
||||||
|
|
||||||
|
|
||||||
def test_reject_forged_from(maildata, gencreds, handler):
|
|
||||||
class env:
|
|
||||||
mail_from = gencreds()[0]
|
|
||||||
rcpt_tos = [gencreds()[0]]
|
|
||||||
|
|
||||||
# test that the filter lets good mail through
|
|
||||||
to_addr = gencreds()[0]
|
|
||||||
env.content = maildata(
|
|
||||||
"encrypted.eml", from_addr=env.mail_from, to_addr=to_addr
|
|
||||||
).as_bytes()
|
|
||||||
|
|
||||||
assert not handler.check_DATA(envelope=env)
|
|
||||||
|
|
||||||
# test that the filter rejects forged mail
|
|
||||||
env.content = maildata(
|
|
||||||
"encrypted.eml", from_addr="forged@c3.testrun.org", to_addr=to_addr
|
|
||||||
).as_bytes()
|
|
||||||
error = handler.check_DATA(envelope=env)
|
|
||||||
assert "500" in error
|
|
||||||
|
|
||||||
|
|
||||||
def test_filtermail_no_encryption_detection(maildata):
|
|
||||||
msg = maildata(
|
|
||||||
"plain.eml", from_addr="some@example.org", to_addr="other@example.org"
|
|
||||||
)
|
|
||||||
assert not check_encrypted(msg)
|
|
||||||
|
|
||||||
# https://xkcd.com/1181/
|
|
||||||
msg = maildata(
|
|
||||||
"fake-encrypted.eml", from_addr="some@example.org", to_addr="other@example.org"
|
|
||||||
)
|
|
||||||
assert not check_encrypted(msg)
|
|
||||||
|
|
||||||
|
|
||||||
def test_filtermail_securejoin_detection(maildata):
|
|
||||||
msg = maildata(
|
|
||||||
"securejoin-vc.eml", from_addr="some@example.org", to_addr="other@example.org"
|
|
||||||
)
|
|
||||||
assert is_securejoin(msg)
|
|
||||||
|
|
||||||
msg = maildata(
|
|
||||||
"securejoin-vc-fake.eml",
|
|
||||||
from_addr="some@example.org",
|
|
||||||
to_addr="other@example.org",
|
|
||||||
)
|
|
||||||
assert not is_securejoin(msg)
|
|
||||||
|
|
||||||
|
|
||||||
def test_filtermail_encryption_detection(maildata):
|
|
||||||
msg = maildata(
|
|
||||||
"encrypted.eml",
|
|
||||||
from_addr="1@example.org",
|
|
||||||
to_addr="2@example.org",
|
|
||||||
subject="Subject does not matter, will be replaced anyway",
|
|
||||||
)
|
|
||||||
assert check_encrypted(msg)
|
|
||||||
|
|
||||||
|
|
||||||
def test_filtermail_no_literal_packets(maildata):
|
|
||||||
"""Test that literal OpenPGP packet is not considered an encrypted mail."""
|
|
||||||
msg = maildata("literal.eml", from_addr="1@example.org", to_addr="2@example.org")
|
|
||||||
assert not check_encrypted(msg)
|
|
||||||
|
|
||||||
|
|
||||||
def test_filtermail_unencrypted_mdn(maildata, gencreds):
|
|
||||||
"""Unencrypted MDNs should not pass."""
|
|
||||||
from_addr = gencreds()[0]
|
|
||||||
to_addr = gencreds()[0] + ".other"
|
|
||||||
msg = maildata("mdn.eml", from_addr=from_addr, to_addr=to_addr)
|
|
||||||
|
|
||||||
assert not check_encrypted(msg)
|
|
||||||
|
|
||||||
|
|
||||||
def test_send_rate_limiter():
|
|
||||||
limiter = SendRateLimiter()
|
|
||||||
for i in range(100):
|
|
||||||
if limiter.is_sending_allowed("some@example.org", 10):
|
|
||||||
if i <= 10:
|
|
||||||
continue
|
|
||||||
pytest.fail("limiter didn't work")
|
|
||||||
else:
|
|
||||||
assert i == 11
|
|
||||||
break
|
|
||||||
|
|
||||||
|
|
||||||
def test_cleartext_excempt_privacy(maildata, gencreds, handler):
|
|
||||||
from_addr = gencreds()[0]
|
|
||||||
to_addr = "privacy@testrun.org"
|
|
||||||
handler.config.passthrough_recipients = [to_addr]
|
|
||||||
false_to = "privacy@something.org"
|
|
||||||
|
|
||||||
msg = maildata("plain.eml", from_addr=from_addr, to_addr=to_addr)
|
|
||||||
|
|
||||||
class env:
|
|
||||||
mail_from = from_addr
|
|
||||||
rcpt_tos = [to_addr]
|
|
||||||
content = msg.as_bytes()
|
|
||||||
|
|
||||||
# assert that None/no error is returned
|
|
||||||
assert not handler.check_DATA(envelope=env)
|
|
||||||
|
|
||||||
class env2:
|
|
||||||
mail_from = from_addr
|
|
||||||
rcpt_tos = [to_addr, false_to]
|
|
||||||
content = msg.as_bytes()
|
|
||||||
|
|
||||||
assert "523" in handler.check_DATA(envelope=env2)
|
|
||||||
|
|
||||||
|
|
||||||
def test_cleartext_self_send_autocrypt_setup_message(maildata, gencreds, handler):
|
|
||||||
from_addr = gencreds()[0]
|
|
||||||
to_addr = from_addr
|
|
||||||
|
|
||||||
msg = maildata("asm.eml", from_addr=from_addr, to_addr=to_addr)
|
|
||||||
|
|
||||||
class env:
|
|
||||||
mail_from = from_addr
|
|
||||||
rcpt_tos = [to_addr]
|
|
||||||
content = msg.as_bytes()
|
|
||||||
|
|
||||||
assert not handler.check_DATA(envelope=env)
|
|
||||||
|
|
||||||
|
|
||||||
def test_cleartext_send_fails(maildata, gencreds, handler):
|
|
||||||
from_addr = gencreds()[0]
|
|
||||||
to_addr = gencreds()[0]
|
|
||||||
|
|
||||||
msg = maildata("plain.eml", from_addr=from_addr, to_addr=to_addr)
|
|
||||||
|
|
||||||
class env:
|
|
||||||
mail_from = from_addr
|
|
||||||
rcpt_tos = [to_addr]
|
|
||||||
content = msg.as_bytes()
|
|
||||||
|
|
||||||
res = handler.check_DATA(envelope=env)
|
|
||||||
assert "523 Encryption Needed" in res
|
|
||||||
|
|
||||||
|
|
||||||
def test_cleartext_incoming_fails(maildata, gencreds, inhandler):
|
|
||||||
from_addr = gencreds()[0]
|
|
||||||
to_addr, password = gencreds()
|
|
||||||
|
|
||||||
msg = maildata("plain.eml", from_addr=from_addr, to_addr=to_addr)
|
|
||||||
|
|
||||||
class env:
|
|
||||||
mail_from = from_addr
|
|
||||||
rcpt_tos = [to_addr]
|
|
||||||
content = msg.as_bytes()
|
|
||||||
|
|
||||||
user = inhandler.config.get_user(to_addr)
|
|
||||||
user.set_password(password)
|
|
||||||
res = inhandler.check_DATA(envelope=env)
|
|
||||||
assert "523 Encryption Needed" in res
|
|
||||||
|
|
||||||
user.allow_incoming_cleartext()
|
|
||||||
assert not inhandler.check_DATA(envelope=env)
|
|
||||||
|
|
||||||
|
|
||||||
def test_cleartext_incoming_mailer_daemon(maildata, gencreds, inhandler):
|
|
||||||
from_addr = "mailer-daemon@example.org"
|
|
||||||
to_addr = gencreds()[0]
|
|
||||||
|
|
||||||
msg = maildata("mailer-daemon.eml", from_addr=from_addr, to_addr=to_addr)
|
|
||||||
|
|
||||||
class env:
|
|
||||||
mail_from = from_addr
|
|
||||||
rcpt_tos = [to_addr]
|
|
||||||
content = msg.as_bytes()
|
|
||||||
|
|
||||||
assert not inhandler.check_DATA(envelope=env)
|
|
||||||
|
|
||||||
|
|
||||||
def test_cleartext_passthrough_domains(maildata, gencreds, handler):
|
|
||||||
from_addr = gencreds()[0]
|
|
||||||
to_addr = "privacy@x.y.z"
|
|
||||||
handler.config.passthrough_recipients = ["@x.y.z"]
|
|
||||||
false_to = "something@x.y"
|
|
||||||
|
|
||||||
msg = maildata("plain.eml", from_addr=from_addr, to_addr=to_addr)
|
|
||||||
|
|
||||||
class env:
|
|
||||||
mail_from = from_addr
|
|
||||||
rcpt_tos = [to_addr]
|
|
||||||
content = msg.as_bytes()
|
|
||||||
|
|
||||||
# assert that None/no error is returned
|
|
||||||
assert not handler.check_DATA(envelope=env)
|
|
||||||
|
|
||||||
class env2:
|
|
||||||
mail_from = from_addr
|
|
||||||
rcpt_tos = [to_addr, false_to]
|
|
||||||
content = msg.as_bytes()
|
|
||||||
|
|
||||||
assert "523" in handler.check_DATA(envelope=env2)
|
|
||||||
|
|
||||||
|
|
||||||
def test_cleartext_passthrough_senders(gencreds, handler, maildata):
|
|
||||||
acc1 = gencreds()[0]
|
|
||||||
to_addr = "recipient@something.org"
|
|
||||||
handler.config.passthrough_senders = [acc1]
|
|
||||||
|
|
||||||
msg = maildata("plain.eml", from_addr=acc1, to_addr=to_addr)
|
|
||||||
|
|
||||||
class env:
|
|
||||||
mail_from = acc1
|
|
||||||
rcpt_tos = to_addr
|
|
||||||
content = msg.as_bytes()
|
|
||||||
|
|
||||||
# assert that None/no error is returned
|
|
||||||
assert not handler.check_DATA(envelope=env)
|
|
||||||
|
|
||||||
|
|
||||||
def test_check_armored_payload():
|
|
||||||
prefix = "-----BEGIN PGP MESSAGE-----\r\n"
|
|
||||||
comment = "Version: ProtonMail\r\n"
|
|
||||||
payload = """\r
|
|
||||||
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\r
|
|
||||||
=b5Kp\r
|
|
||||||
-----END PGP MESSAGE-----\r
|
|
||||||
\r
|
|
||||||
\r
|
|
||||||
"""
|
|
||||||
|
|
||||||
commented_payload = prefix + comment + payload
|
|
||||||
assert check_armored_payload(commented_payload, outgoing=False) == True
|
|
||||||
assert check_armored_payload(commented_payload, outgoing=True) == False
|
|
||||||
|
|
||||||
payload = prefix + payload
|
|
||||||
assert check_armored_payload(payload, outgoing=False) == True
|
|
||||||
assert check_armored_payload(payload, outgoing=True) == True
|
|
||||||
|
|
||||||
payload = payload.removesuffix("\r\n")
|
|
||||||
assert check_armored_payload(payload, outgoing=False) == True
|
|
||||||
assert check_armored_payload(payload, outgoing=True) == True
|
|
||||||
|
|
||||||
payload = payload.removesuffix("\r\n")
|
|
||||||
assert check_armored_payload(payload, outgoing=False) == True
|
|
||||||
assert check_armored_payload(payload, outgoing=True) == True
|
|
||||||
|
|
||||||
payload = payload.removesuffix("\r\n")
|
|
||||||
assert check_armored_payload(payload, outgoing=False) == True
|
|
||||||
assert check_armored_payload(payload, outgoing=True) == True
|
|
||||||
|
|
||||||
payload = """-----BEGIN PGP MESSAGE-----\r
|
|
||||||
\r
|
|
||||||
HELLOWORLD
|
|
||||||
-----END PGP MESSAGE-----\r
|
|
||||||
\r
|
|
||||||
"""
|
|
||||||
assert check_armored_payload(payload, outgoing=False) == False
|
|
||||||
assert check_armored_payload(payload, outgoing=True) == False
|
|
||||||
|
|
||||||
payload = """-----BEGIN PGP MESSAGE-----\r
|
|
||||||
\r
|
|
||||||
=njUN
|
|
||||||
-----END PGP MESSAGE-----\r
|
|
||||||
\r
|
|
||||||
"""
|
|
||||||
assert check_armored_payload(payload, outgoing=False) == False
|
|
||||||
assert check_armored_payload(payload, outgoing=True) == False
|
|
||||||
|
|
||||||
# Test payload using partial body length
|
|
||||||
# as generated by GopenPGP.
|
|
||||||
payload = """-----BEGIN PGP MESSAGE-----\r
|
|
||||||
\r
|
|
||||||
wV4DdCVjRfOT3TQSAQdAY5+pjT6mlCxPGdR3be4w7oJJRUGIPI/Vnh+mJxGSm34w\r
|
|
||||||
LNlVc89S1g22uQYFif2sUJsQWbpoHpNkuWpkSgOaHmNvrZiY/YU5iv+cZ3LbmtUG\r
|
|
||||||
0uoBisSHh9O1c+5sYZSbrvYZ1NOwlD7Fv/U5/Mw4E5+CjxfdgNGp5o3DDddzPK78\r
|
|
||||||
jseDhdSXxnaiIJC93hxNX6R1RPt3G2gukyzx69wciPQShcF8zf3W3o75Ed7B8etV\r
|
|
||||||
QEeB16xzdFhKa9JxdjTu3osgCs21IO7wpcFkjc7nZzlW6jPnELJJaNmv4yOOCjMp\r
|
|
||||||
6YAkaN/BkL+jHTznHDuDsT5ilnTXpwHDU1Cm9PIx/KFcNCQnIB+2DcdIHPHUH1ci\r
|
|
||||||
jvqoeXAVWjKXEjS7PqPFuP/xGbrWG2ugs+toXJOKbgRkExvKs1dwPFKrgghvCVbW\r
|
|
||||||
AcKejQKAPArLwpkA7aD875TZQShvGt74fNs45XBlGOYOnNOAJ1KAmzrXLIDViyyB\r
|
|
||||||
kDsmTBk785xofuCkjBpXSe6vsMprPzCteDfaUibh8FHeJjucxPerwuOPEmnogNaf\r
|
|
||||||
YyL4+iy8H8I9/p7pmUqILprxTG0jTOtlk0bTVzeiF56W1xbtSEMuOo4oFbQTyOM2\r
|
|
||||||
bKXaYo774Jm+rRtKAnnI2dtf9RpK19cog6YNzfYjesLKbXDsPZbN5rmwyFiCvvxC\r
|
|
||||||
kQ6JLob+B2fPdY2gzy7LypxktS8Zi1HJcWDHJGVmQodaDLqKUObb4M26bXDe6oxI\r
|
|
||||||
NS8PJz5exVbM3KhZnUOEn6PJRBBf5a/ZqxlhZPcQo/oBuhKpBRpO5kSDwPIUByu3\r
|
|
||||||
UlXLSkpMqe9pUarAOEuQjfl2RVY7U+RrQYp4YP5keMO+i8NCefAFbowTTufO1JIq\r
|
|
||||||
2nVgCi/QVnxZyEc9OYt/8AE3g4cdojE+vsSDifZLSWYIetpfrohHv3dT3StD1QRG\r
|
|
||||||
0QE6qq6oKpg/IL0cjvuX4c7a7bslv2fXp8t75y37RU6253qdIebhxc/cRhPbc/yu\r
|
|
||||||
p0YLyD4SrvKTLP2ZV95jT4IPEpqm4AN3QmiOzdtqR2gLyb62L8QfqI/FdwsIiRiM\r
|
|
||||||
hqydwoqt/lfSqG1WKPh+6EkMkH+TDiCC1BQdbN1MNcyUtcjb35PR2c8Ld2TF3guA\r
|
|
||||||
jLIqMt/Vb7hBoMb2FcsOYY25ka9oV62OwgKWLXnFzk+modMR5fzb4kxVVAYEqP+D\r
|
|
||||||
T5KO1Vs76v1fyPGOq6BbBCvLwTqe/e6IZInJles4v5jrhnLcGKmNGivCUDe6X6NY\r
|
|
||||||
UKNt5RsZllwDQpaAb5dMNhyrk8SgIE7TBI7rvqIdUCE52Vy+0JDxFg5olRpFUfO6\r
|
|
||||||
/MyTW3Yo/ekk/npHr7iYYqJTCc21bDGLWQcIo/XO7WPxrKNWGBNPFnkRdw0MaKr4\r
|
|
||||||
+cEM3V8NFnSEpC12xA+RX/CezuJtwXZK5MpG76eYqMO6qyC+c25YcFecEufDZDxx\r
|
|
||||||
ZLqRszVRyxyWPtk/oIeQK2v9wOqY6N9/ff01gHz69vqYqN5bUw/QKZsmx1zW+gPw\r
|
|
||||||
6x2tDK2BHeYl182gCbhlKISRFwCtbjqZSkiKWao/VtygHkw0fK34avJuyQ/X9YaN\r
|
|
||||||
BRy+7Lf3VA53pnB5WJ1xwRXN8VDvmZeXzv2krHveCMemj0OjnRoCLu117xN0A5m9\r
|
|
||||||
Fm/RoDix5PolDHtWTtr2m1n2hp2LHnj8at9lFEd0SKhAYHVL9KjzycwWODZRXt+x\r
|
|
||||||
zGDDuooEeTvdY5NLyKcl4gETz1ZP4Ez5jGGjhPSwSpq1mU7UaJ9ZXXdr4KHyifW6\r
|
|
||||||
ggNzNsGhXTap7IWZpTtqXABydfiBshmH2NjqtNDwBweJVSgP10+r0WhMWlaZs6xl\r
|
|
||||||
V3o5yskJt6GlkwpJxZrTvN6Tiww/eW7HFV6NGf7IRSWY5tJc/iA7/92tOmkdvJ1q\r
|
|
||||||
myLbG7cJB787QjplEyVe2P/JBO6xYvbkJLf9Q+HaviTO25rugRSrYsoKMDfO8VlQ\r
|
|
||||||
1CcnTPVtApPZJEQzAWJEgVAM8uIlkqWJJMgyWT34sTkdBeCUFGloXQFs9Yxd0AGf\r
|
|
||||||
/zHEkYZSTKpVSvAIGu4=\r
|
|
||||||
=6iHb\r
|
|
||||||
-----END PGP MESSAGE-----\r
|
|
||||||
"""
|
|
||||||
assert check_armored_payload(payload, outgoing=False) == True
|
|
||||||
assert check_armored_payload(payload, outgoing=True) == True
|
|
||||||
@@ -5,6 +5,11 @@ import os
|
|||||||
from pyinfra.operations import files, server, systemd
|
from pyinfra.operations import files, server, systemd
|
||||||
|
|
||||||
|
|
||||||
|
def has_systemd():
|
||||||
|
"""Returns False during Docker image builds or any other non-systemd environment."""
|
||||||
|
return os.path.isdir("/run/systemd/system")
|
||||||
|
|
||||||
|
|
||||||
def get_resource(arg, pkg=__package__):
|
def get_resource(arg, pkg=__package__):
|
||||||
return importlib.resources.files(pkg).joinpath(arg)
|
return importlib.resources.files(pkg).joinpath(arg)
|
||||||
|
|
||||||
@@ -17,9 +22,8 @@ def configure_remote_units(mail_domain, units) -> None:
|
|||||||
|
|
||||||
# install systemd units
|
# install systemd units
|
||||||
for fn in units:
|
for fn in units:
|
||||||
execpath = fn if fn != "filtermail-incoming" else "filtermail"
|
|
||||||
params = dict(
|
params = dict(
|
||||||
execpath=f"{remote_venv_dir}/bin/{execpath}",
|
execpath=f"{remote_venv_dir}/bin/{fn}",
|
||||||
config_path=remote_chatmail_inipath,
|
config_path=remote_chatmail_inipath,
|
||||||
remote_venv_dir=remote_venv_dir,
|
remote_venv_dir=remote_venv_dir,
|
||||||
mail_domain=mail_domain,
|
mail_domain=mail_domain,
|
||||||
|
|||||||
@@ -101,11 +101,16 @@ def run_cmd(args, out):
|
|||||||
env["CHATMAIL_WEBSITE_ONLY"] = "True" if args.website_only else ""
|
env["CHATMAIL_WEBSITE_ONLY"] = "True" if args.website_only else ""
|
||||||
env["CHATMAIL_DISABLE_MAIL"] = "True" if args.disable_mail else ""
|
env["CHATMAIL_DISABLE_MAIL"] = "True" if args.disable_mail else ""
|
||||||
env["CHATMAIL_REQUIRE_IROH"] = "True" if require_iroh else ""
|
env["CHATMAIL_REQUIRE_IROH"] = "True" if require_iroh else ""
|
||||||
|
if not args.dns_check_disabled:
|
||||||
|
env["CHATMAIL_ADDR_V4"] = remote_data.get("A") or ""
|
||||||
|
env["CHATMAIL_ADDR_V6"] = remote_data.get("AAAA") or ""
|
||||||
deploy_path = importlib.resources.files(__package__).joinpath("run.py").resolve()
|
deploy_path = importlib.resources.files(__package__).joinpath("run.py").resolve()
|
||||||
pyinf = "pyinfra --dry" if args.dry_run else "pyinfra"
|
pyinf = "pyinfra --dry" if args.dry_run else "pyinfra"
|
||||||
|
|
||||||
cmd = f"{pyinf} --ssh-user root {ssh_host} {deploy_path} -y"
|
cmd = f"{pyinf} --ssh-user root {ssh_host} {deploy_path} -y"
|
||||||
if ssh_host in ["localhost", "@docker"]:
|
if ssh_host in ["localhost", "@docker"]:
|
||||||
|
if ssh_host == "@docker":
|
||||||
|
env["CHATMAIL_DOCKER"] = "True"
|
||||||
cmd = f"{pyinf} @local {deploy_path} -y"
|
cmd = f"{pyinf} @local {deploy_path} -y"
|
||||||
|
|
||||||
if version.parse(pyinfra.__version__) < version.parse("3"):
|
if version.parse(pyinfra.__version__) < version.parse("3"):
|
||||||
@@ -121,7 +126,7 @@ def run_cmd(args, out):
|
|||||||
out.red("Website deployment failed.")
|
out.red("Website deployment failed.")
|
||||||
elif retcode == 0:
|
elif retcode == 0:
|
||||||
out.green("Deploy completed, call `cmdeploy dns` next.")
|
out.green("Deploy completed, call `cmdeploy dns` next.")
|
||||||
elif not remote_data["acme_account_url"]:
|
elif not args.dns_check_disabled and not remote_data["acme_account_url"]:
|
||||||
out.red("Deploy completed but letsencrypt not configured")
|
out.red("Deploy completed but letsencrypt not configured")
|
||||||
out.red("Run 'cmdeploy run' again")
|
out.red("Run 'cmdeploy run' again")
|
||||||
retcode = 0
|
retcode = 0
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from chatmaild.config import read_config
|
from chatmaild.config import read_config
|
||||||
from pyinfra import facts, host, logger
|
from pyinfra import facts, host, logger
|
||||||
|
from pyinfra.facts import hardware
|
||||||
from pyinfra.api import FactBase
|
from pyinfra.api import FactBase
|
||||||
from pyinfra.facts.files import Sha256File
|
from pyinfra.facts.files import Sha256File
|
||||||
from pyinfra.facts.systemd import SystemdEnabled
|
from pyinfra.facts.systemd import SystemdEnabled
|
||||||
@@ -24,8 +25,10 @@ from .basedeploy import (
|
|||||||
activate_remote_units,
|
activate_remote_units,
|
||||||
configure_remote_units,
|
configure_remote_units,
|
||||||
get_resource,
|
get_resource,
|
||||||
|
has_systemd,
|
||||||
)
|
)
|
||||||
from .dovecot.deployer import DovecotDeployer
|
from .dovecot.deployer import DovecotDeployer
|
||||||
|
from .filtermail.deployer import FiltermailDeployer
|
||||||
from .mtail.deployer import MtailDeployer
|
from .mtail.deployer import MtailDeployer
|
||||||
from .nginx.deployer import NginxDeployer
|
from .nginx.deployer import NginxDeployer
|
||||||
from .opendkim.deployer import OpendkimDeployer
|
from .opendkim.deployer import OpendkimDeployer
|
||||||
@@ -35,7 +38,7 @@ from .www import build_webpages, find_merge_conflict, get_paths
|
|||||||
|
|
||||||
class Port(FactBase):
|
class Port(FactBase):
|
||||||
"""
|
"""
|
||||||
Returns the process occuping a port.
|
Returns the process occupying a port.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def command(self, port: int) -> str:
|
def command(self, port: int) -> str:
|
||||||
@@ -63,6 +66,8 @@ def _build_chatmaild(dist_dir) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def remove_legacy_artifacts():
|
def remove_legacy_artifacts():
|
||||||
|
if not has_systemd():
|
||||||
|
return
|
||||||
# disable legacy doveauth-dictproxy.service
|
# disable legacy doveauth-dictproxy.service
|
||||||
if host.get_fact(SystemdEnabled).get("doveauth-dictproxy.service"):
|
if host.get_fact(SystemdEnabled).get("doveauth-dictproxy.service"):
|
||||||
systemd.service(
|
systemd.service(
|
||||||
@@ -140,6 +145,10 @@ def _configure_remote_venv_with_chatmaild(config) -> None:
|
|||||||
|
|
||||||
|
|
||||||
class UnboundDeployer(Deployer):
|
class UnboundDeployer(Deployer):
|
||||||
|
def __init__(self, config):
|
||||||
|
self.config = config
|
||||||
|
self.need_restart = False
|
||||||
|
|
||||||
def install(self):
|
def install(self):
|
||||||
# Run local DNS resolver `unbound`.
|
# Run local DNS resolver `unbound`.
|
||||||
# `resolvconf` takes care of setting up /etc/resolv.conf
|
# `resolvconf` takes care of setting up /etc/resolv.conf
|
||||||
@@ -176,6 +185,27 @@ class UnboundDeployer(Deployer):
|
|||||||
"unbound-anchor -a /var/lib/unbound/root.key || true",
|
"unbound-anchor -a /var/lib/unbound/root.key || true",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
if self.config.disable_ipv6:
|
||||||
|
files.directory(
|
||||||
|
path="/etc/unbound/unbound.conf.d",
|
||||||
|
present=True,
|
||||||
|
user="root",
|
||||||
|
group="root",
|
||||||
|
mode="755",
|
||||||
|
)
|
||||||
|
conf = files.put(
|
||||||
|
src=get_resource("unbound/unbound.conf.j2"),
|
||||||
|
dest="/etc/unbound/unbound.conf.d/chatmail.conf",
|
||||||
|
user="root",
|
||||||
|
group="root",
|
||||||
|
mode="644",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
conf = files.file(
|
||||||
|
path="/etc/unbound/unbound.conf.d/chatmail.conf",
|
||||||
|
present=False,
|
||||||
|
)
|
||||||
|
self.need_restart |= conf.changed
|
||||||
|
|
||||||
def activate(self):
|
def activate(self):
|
||||||
server.shell(
|
server.shell(
|
||||||
@@ -190,6 +220,7 @@ class UnboundDeployer(Deployer):
|
|||||||
service="unbound.service",
|
service="unbound.service",
|
||||||
running=True,
|
running=True,
|
||||||
enabled=True,
|
enabled=True,
|
||||||
|
restarted=self.need_restart,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -271,7 +302,7 @@ class LegacyRemoveDeployer(Deployer):
|
|||||||
present=False,
|
present=False,
|
||||||
)
|
)
|
||||||
# remove echobot if it is still running
|
# remove echobot if it is still running
|
||||||
if host.get_fact(SystemdEnabled).get("echobot.service"):
|
if has_systemd() and host.get_fact(SystemdEnabled).get("echobot.service"):
|
||||||
systemd.service(
|
systemd.service(
|
||||||
name="Disable echobot.service",
|
name="Disable echobot.service",
|
||||||
service="echobot.service",
|
service="echobot.service",
|
||||||
@@ -416,8 +447,6 @@ class ChatmailVenvDeployer(Deployer):
|
|||||||
def __init__(self, config):
|
def __init__(self, config):
|
||||||
self.config = config
|
self.config = config
|
||||||
self.units = (
|
self.units = (
|
||||||
"filtermail",
|
|
||||||
"filtermail-incoming",
|
|
||||||
"chatmail-metadata",
|
"chatmail-metadata",
|
||||||
"lastlogin",
|
"lastlogin",
|
||||||
"chatmail-expire",
|
"chatmail-expire",
|
||||||
@@ -509,12 +538,13 @@ class GithashDeployer(Deployer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -> None:
|
def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool, docker: bool) -> None:
|
||||||
"""Deploy a chat-mail instance.
|
"""Deploy a chat-mail instance.
|
||||||
|
|
||||||
:param config_path: path to chatmail.ini
|
:param config_path: path to chatmail.ini
|
||||||
:param disable_mail: whether to disable postfix & dovecot
|
:param disable_mail: whether to disable postfix & dovecot
|
||||||
:param website_only: if True, only deploy the website
|
:param website_only: if True, only deploy the website
|
||||||
|
:param docker: whether it is running in a docker container
|
||||||
"""
|
"""
|
||||||
config = read_config(config_path)
|
config = read_config(config_path)
|
||||||
check_config(config)
|
check_config(config)
|
||||||
@@ -528,44 +558,56 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
|
|||||||
files.line(
|
files.line(
|
||||||
name="Add 9.9.9.9 to resolv.conf",
|
name="Add 9.9.9.9 to resolv.conf",
|
||||||
path="/etc/resolv.conf",
|
path="/etc/resolv.conf",
|
||||||
line="nameserver 9.9.9.9",
|
# Guard against resolv.conf missing a trailing newline (SolusVM bug).
|
||||||
|
line="\nnameserver 9.9.9.9",
|
||||||
)
|
)
|
||||||
|
|
||||||
port_services = [
|
# Check if mtail_address interface is available (if configured)
|
||||||
(["master", "smtpd"], 25),
|
if config.mtail_address and config.mtail_address not in ('127.0.0.1', '::1', 'localhost'):
|
||||||
("unbound", 53),
|
ipv4_addrs = host.get_fact(hardware.Ipv4Addrs)
|
||||||
("acmetool", 80),
|
all_addresses = [addr for addrs in ipv4_addrs.values() for addr in addrs]
|
||||||
(["imap-login", "dovecot"], 143),
|
if config.mtail_address not in all_addresses:
|
||||||
("nginx", 443),
|
Out().red(f"Deploy failed: mtail_address {config.mtail_address} is not available (VPN up?).\n")
|
||||||
(["master", "smtpd"], 465),
|
exit(1)
|
||||||
(["master", "smtpd"], 587),
|
|
||||||
(["imap-login", "dovecot"], 993),
|
if not docker:
|
||||||
("iroh-relay", 3340),
|
port_services = [
|
||||||
("mtail", 3903),
|
(["master", "smtpd"], 25),
|
||||||
("dovecot-stats", 3904),
|
("unbound", 53),
|
||||||
("nginx", 8443),
|
("acmetool", 80),
|
||||||
(["master", "smtpd"], config.postfix_reinject_port),
|
(["imap-login", "dovecot"], 143),
|
||||||
(["master", "smtpd"], config.postfix_reinject_port_incoming),
|
("nginx", 443),
|
||||||
("filtermail", config.filtermail_smtp_port),
|
(["master", "smtpd"], 465),
|
||||||
("filtermail", config.filtermail_smtp_port_incoming),
|
(["master", "smtpd"], 587),
|
||||||
]
|
(["imap-login", "dovecot"], 993),
|
||||||
for service, port in port_services:
|
("iroh-relay", 3340),
|
||||||
print(f"Checking if port {port} is available for {service}...")
|
("mtail", 3903),
|
||||||
running_service = host.get_fact(Port, port=port)
|
("stats", 3904),
|
||||||
if running_service:
|
("nginx", 8443),
|
||||||
if running_service not in service:
|
(["master", "smtpd"], config.postfix_reinject_port),
|
||||||
Out().red(
|
(["master", "smtpd"], config.postfix_reinject_port_incoming),
|
||||||
f"Deploy failed: port {port} is occupied by: {running_service}"
|
("filtermail", config.filtermail_smtp_port),
|
||||||
)
|
("filtermail", config.filtermail_smtp_port_incoming),
|
||||||
exit(1)
|
]
|
||||||
|
for service, port in port_services:
|
||||||
|
print(f"Checking if port {port} is available for {service}...")
|
||||||
|
running_service = host.get_fact(Port, port=port)
|
||||||
|
services = [service] if isinstance(service, str) else service
|
||||||
|
if running_service:
|
||||||
|
if running_service not in services:
|
||||||
|
Out().red(
|
||||||
|
f"Deploy failed: port {port} is occupied by: {running_service}"
|
||||||
|
)
|
||||||
|
exit(1)
|
||||||
|
|
||||||
tls_domains = [mail_domain, f"mta-sts.{mail_domain}", f"www.{mail_domain}"]
|
tls_domains = [mail_domain, f"mta-sts.{mail_domain}", f"www.{mail_domain}"]
|
||||||
|
|
||||||
all_deployers = [
|
all_deployers = [
|
||||||
ChatmailDeployer(mail_domain),
|
ChatmailDeployer(mail_domain),
|
||||||
LegacyRemoveDeployer(),
|
LegacyRemoveDeployer(),
|
||||||
|
FiltermailDeployer(),
|
||||||
JournaldDeployer(),
|
JournaldDeployer(),
|
||||||
UnboundDeployer(),
|
UnboundDeployer(config),
|
||||||
TurnDeployer(mail_domain),
|
TurnDeployer(mail_domain),
|
||||||
IrohDeployer(config.enable_iroh_relay),
|
IrohDeployer(config.enable_iroh_relay),
|
||||||
AcmetoolDeployer(config.acme_email, tls_domains),
|
AcmetoolDeployer(config.acme_email, tls_domains),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from cmdeploy.basedeploy import (
|
|||||||
activate_remote_units,
|
activate_remote_units,
|
||||||
configure_remote_units,
|
configure_remote_units,
|
||||||
get_resource,
|
get_resource,
|
||||||
|
has_systemd,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -22,10 +23,11 @@ class DovecotDeployer(Deployer):
|
|||||||
|
|
||||||
def install(self):
|
def install(self):
|
||||||
arch = host.get_fact(Arch)
|
arch = host.get_fact(Arch)
|
||||||
if not "dovecot.service" in host.get_fact(SystemdEnabled):
|
if has_systemd() and "dovecot.service" in host.get_fact(SystemdEnabled):
|
||||||
_install_dovecot_package("core", arch)
|
return # already installed and running
|
||||||
_install_dovecot_package("imapd", arch)
|
_install_dovecot_package("core", arch)
|
||||||
_install_dovecot_package("lmtpd", arch)
|
_install_dovecot_package("imapd", arch)
|
||||||
|
_install_dovecot_package("lmtpd", arch)
|
||||||
|
|
||||||
def configure(self):
|
def configure(self):
|
||||||
configure_remote_units(self.config.mail_domain, self.units)
|
configure_remote_units(self.config.mail_domain, self.units)
|
||||||
@@ -116,18 +118,19 @@ def _configure_dovecot(config: Config, debug: bool = False) -> (bool, bool):
|
|||||||
|
|
||||||
# as per https://doc.dovecot.org/2.3/configuration_manual/os/
|
# as per https://doc.dovecot.org/2.3/configuration_manual/os/
|
||||||
# it is recommended to set the following inotify limits
|
# it is recommended to set the following inotify limits
|
||||||
for name in ("max_user_instances", "max_user_watches"):
|
if config.change_kernel_settings:
|
||||||
key = f"fs.inotify.{name}"
|
for name in ("max_user_instances", "max_user_watches"):
|
||||||
if host.get_fact(Sysctl)[key] > 65535:
|
key = f"fs.inotify.{name}"
|
||||||
# Skip updating limits if already sufficient
|
if host.get_fact(Sysctl)[key] > 65535:
|
||||||
# (enables running in incus containers where sysctl readonly)
|
# Skip updating limits if already sufficient
|
||||||
continue
|
# (enables running in incus containers where sysctl readonly)
|
||||||
server.sysctl(
|
continue
|
||||||
name=f"Change {key}",
|
server.sysctl(
|
||||||
key=key,
|
name=f"Change {key}",
|
||||||
value=65535,
|
key=key,
|
||||||
persist=True,
|
value=65535,
|
||||||
)
|
persist=True,
|
||||||
|
)
|
||||||
|
|
||||||
timezone_env = files.line(
|
timezone_env = files.line(
|
||||||
name="Set TZ environment variable",
|
name="Set TZ environment variable",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
## Dovecot configuration file
|
## Dovecot configuration file
|
||||||
|
|
||||||
{% if disable_ipv6 %}
|
{% if disable_ipv6 %}
|
||||||
listen = *
|
listen = 0.0.0.0
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
protocols = imap lmtp
|
protocols = imap lmtp
|
||||||
|
|||||||
52
cmdeploy/src/cmdeploy/filtermail/deployer.py
Normal file
52
cmdeploy/src/cmdeploy/filtermail/deployer.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
from pyinfra import facts, host
|
||||||
|
from pyinfra.operations import files, systemd
|
||||||
|
|
||||||
|
from cmdeploy.basedeploy import Deployer, get_resource
|
||||||
|
|
||||||
|
|
||||||
|
class FiltermailDeployer(Deployer):
|
||||||
|
services = ["filtermail", "filtermail-incoming"]
|
||||||
|
bin_path = "/usr/local/bin/filtermail"
|
||||||
|
config_path = "/usr/local/lib/chatmaild/chatmail.ini"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.need_restart = False
|
||||||
|
|
||||||
|
def install(self):
|
||||||
|
arch = host.get_fact(facts.server.Arch)
|
||||||
|
url = f"https://github.com/chatmail/filtermail/releases/download/v0.3.0/filtermail-{arch}"
|
||||||
|
sha256sum = {
|
||||||
|
"x86_64": "f14a31323ae2dad3b59d3fdafcde507521da2f951a9478cd1f2fe2b4463df71d",
|
||||||
|
"aarch64": "933770d75046c4fd7084ce8d43f905f8748333426ad839154f0fc654755ef09f",
|
||||||
|
}[arch]
|
||||||
|
self.need_restart |= files.download(
|
||||||
|
name="Download filtermail",
|
||||||
|
src=url,
|
||||||
|
sha256sum=sha256sum,
|
||||||
|
dest=self.bin_path,
|
||||||
|
mode="755",
|
||||||
|
).changed
|
||||||
|
|
||||||
|
def configure(self):
|
||||||
|
for service in self.services:
|
||||||
|
self.need_restart |= files.template(
|
||||||
|
src=get_resource(f"filtermail/{service}.service.j2"),
|
||||||
|
dest=f"/etc/systemd/system/{service}.service",
|
||||||
|
user="root",
|
||||||
|
group="root",
|
||||||
|
mode="644",
|
||||||
|
bin_path=self.bin_path,
|
||||||
|
config_path=self.config_path,
|
||||||
|
).changed
|
||||||
|
|
||||||
|
def activate(self):
|
||||||
|
for service in self.services:
|
||||||
|
systemd.service(
|
||||||
|
name=f"Start and enable {service}",
|
||||||
|
service=f"{service}.service",
|
||||||
|
running=True,
|
||||||
|
enabled=True,
|
||||||
|
restarted=self.need_restart,
|
||||||
|
daemon_reload=True,
|
||||||
|
)
|
||||||
|
self.need_restart = False
|
||||||
@@ -2,11 +2,10 @@
|
|||||||
Description=Incoming Chatmail Postfix before queue filter
|
Description=Incoming Chatmail Postfix before queue filter
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
ExecStart={execpath} {config_path} incoming
|
ExecStart={{ bin_path }} {{ config_path }} incoming
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=30
|
RestartSec=30
|
||||||
User=vmail
|
User=vmail
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
Description=Outgoing Chatmail Postfix before queue filter
|
Description=Outgoing Chatmail Postfix before queue filter
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
ExecStart={execpath} {config_path} outgoing
|
ExecStart={{ bin_path }} {{ config_path }} outgoing
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=30
|
RestartSec=30
|
||||||
User=vmail
|
User=vmail
|
||||||
@@ -44,21 +44,37 @@ counter warning_count
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
counter filtered_mail_count
|
counter filtered_outgoing_mail_count
|
||||||
|
|
||||||
counter encrypted_mail_count
|
counter outgoing_encrypted_mail_count
|
||||||
/Filtering encrypted mail\./ {
|
/Outgoing: Filtering encrypted mail\./ {
|
||||||
encrypted_mail_count++
|
outgoing_encrypted_mail_count++
|
||||||
filtered_mail_count++
|
filtered_outgoing_mail_count++
|
||||||
}
|
}
|
||||||
|
|
||||||
counter unencrypted_mail_count
|
counter outgoing_unencrypted_mail_count
|
||||||
/Filtering unencrypted mail\./ {
|
/Outgoing: Filtering unencrypted mail\./ {
|
||||||
unencrypted_mail_count++
|
outgoing_unencrypted_mail_count++
|
||||||
filtered_mail_count++
|
filtered_outgoing_mail_count++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
counter filtered_incoming_mail_count
|
||||||
|
|
||||||
|
counter incoming_encrypted_mail_count
|
||||||
|
/Incoming: Filtering encrypted mail\./ {
|
||||||
|
incoming_encrypted_mail_count++
|
||||||
|
filtered_incoming_mail_count++
|
||||||
|
}
|
||||||
|
|
||||||
|
counter incoming_unencrypted_mail_count
|
||||||
|
/Incoming: Filtering unencrypted mail\./ {
|
||||||
|
incoming_unencrypted_mail_count++
|
||||||
|
filtered_incoming_mail_count++
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
counter rejected_unencrypted_mail_count
|
counter rejected_unencrypted_mail_count
|
||||||
/Rejected unencrypted mail\./ {
|
/Rejected unencrypted mail/ {
|
||||||
rejected_unencrypted_mail_count++
|
rejected_unencrypted_mail_count++
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,20 @@ class PostfixDeployer(Deployer):
|
|||||||
)
|
)
|
||||||
need_restart |= lmtp_header_cleanup.changed
|
need_restart |= lmtp_header_cleanup.changed
|
||||||
|
|
||||||
|
tls_policy_map = files.put(
|
||||||
|
name="Upload SMTP TLS Policy that accepts self-signed certificates for IP-only hosts",
|
||||||
|
src=get_resource("postfix/smtp_tls_policy_map"),
|
||||||
|
dest="/etc/postfix/smtp_tls_policy_map",
|
||||||
|
user="root",
|
||||||
|
group="root",
|
||||||
|
mode="644",
|
||||||
|
)
|
||||||
|
need_restart |= tls_policy_map.changed
|
||||||
|
if tls_policy_map.changed:
|
||||||
|
server.shell(
|
||||||
|
commands=["postmap /etc/postfix/smtp_tls_policy_map"],
|
||||||
|
)
|
||||||
|
|
||||||
# Login map that 1:1 maps email address to login.
|
# Login map that 1:1 maps email address to login.
|
||||||
login_map = files.put(
|
login_map = files.put(
|
||||||
src=get_resource("postfix/login_map"),
|
src=get_resource("postfix/login_map"),
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ smtp_tls_security_level=verify
|
|||||||
# <https://www.postfix.org/postconf.5.html#smtp_tls_servername>
|
# <https://www.postfix.org/postconf.5.html#smtp_tls_servername>
|
||||||
smtp_tls_servername = hostname
|
smtp_tls_servername = hostname
|
||||||
smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache
|
smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache
|
||||||
smtp_tls_policy_maps = inline:{nauta.cu=may}
|
smtp_tls_policy_maps = regexp:/etc/postfix/smtp_tls_policy_map
|
||||||
smtp_tls_protocols = >=TLSv1.2
|
smtp_tls_protocols = >=TLSv1.2
|
||||||
smtp_tls_mandatory_protocols = >=TLSv1.2
|
smtp_tls_mandatory_protocols = >=TLSv1.2
|
||||||
|
|
||||||
@@ -64,7 +64,20 @@ alias_database = hash:/etc/aliases
|
|||||||
mydestination =
|
mydestination =
|
||||||
|
|
||||||
relayhost =
|
relayhost =
|
||||||
|
{% if disable_ipv6 %}
|
||||||
|
mynetworks = 127.0.0.0/8
|
||||||
|
{% else %}
|
||||||
mynetworks = 127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128
|
mynetworks = 127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128
|
||||||
|
{% endif %}
|
||||||
|
{% if config.addr_v4 %}
|
||||||
|
smtp_bind_address = {{ config.addr_v4 }}
|
||||||
|
{% endif %}
|
||||||
|
{% if config.addr_v6 %}
|
||||||
|
smtp_bind_address6 = {{ config.addr_v6 }}
|
||||||
|
{% endif %}
|
||||||
|
{% if config.addr_v4 or config.addr_v6 %}
|
||||||
|
smtp_bind_address_enforce = yes
|
||||||
|
{% endif %}
|
||||||
mailbox_size_limit = 0
|
mailbox_size_limit = 0
|
||||||
message_size_limit = {{config.max_message_size}}
|
message_size_limit = {{config.max_message_size}}
|
||||||
recipient_delimiter = +
|
recipient_delimiter = +
|
||||||
|
|||||||
2
cmdeploy/src/cmdeploy/postfix/smtp_tls_policy_map
Normal file
2
cmdeploy/src/cmdeploy/postfix/smtp_tls_policy_map
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/^\[[^]]+\]$/ encrypt
|
||||||
|
/^nauta\.cu$/ may
|
||||||
@@ -15,8 +15,9 @@ def main():
|
|||||||
)
|
)
|
||||||
disable_mail = bool(os.environ.get("CHATMAIL_DISABLE_MAIL"))
|
disable_mail = bool(os.environ.get("CHATMAIL_DISABLE_MAIL"))
|
||||||
website_only = bool(os.environ.get("CHATMAIL_WEBSITE_ONLY"))
|
website_only = bool(os.environ.get("CHATMAIL_WEBSITE_ONLY"))
|
||||||
|
docker = bool(os.environ.get("CHATMAIL_DOCKER"))
|
||||||
|
|
||||||
deploy_chatmail(config_path, disable_mail, website_only)
|
deploy_chatmail(config_path, disable_mail, website_only, docker)
|
||||||
|
|
||||||
|
|
||||||
if pyinfra.is_cli:
|
if pyinfra.is_cli:
|
||||||
|
|||||||
@@ -189,12 +189,14 @@ def test_exceed_rate_limit(cmsetup, gencreds, maildata, chatmail_config):
|
|||||||
mail = maildata(
|
mail = maildata(
|
||||||
"encrypted.eml", from_addr=user1.addr, to_addr=user2.addr
|
"encrypted.eml", from_addr=user1.addr, to_addr=user2.addr
|
||||||
).as_string()
|
).as_string()
|
||||||
for i in range(chatmail_config.max_user_send_per_minute + 5):
|
|
||||||
print("Sending mail", str(i))
|
start = time.time()
|
||||||
|
for i in range(chatmail_config.max_user_send_per_minute * 3):
|
||||||
|
print("Sending mail", str(i + 1), "at", time.time() - start, "s.")
|
||||||
try:
|
try:
|
||||||
user1.smtp.sendmail(user1.addr, [user2.addr], mail)
|
user1.smtp.sendmail(user1.addr, [user2.addr], mail)
|
||||||
except smtplib.SMTPException as e:
|
except smtplib.SMTPException as e:
|
||||||
if i < chatmail_config.max_user_send_per_minute:
|
if i < chatmail_config.max_user_send_burst_size:
|
||||||
pytest.fail(f"rate limit was exceeded too early with msg {i}")
|
pytest.fail(f"rate limit was exceeded too early with msg {i}")
|
||||||
outcome = e.recipients[user2.addr]
|
outcome = e.recipients[user2.addr]
|
||||||
assert outcome[0] == 450
|
assert outcome[0] == 450
|
||||||
|
|||||||
4
cmdeploy/src/cmdeploy/unbound/unbound.conf.j2
Normal file
4
cmdeploy/src/cmdeploy/unbound/unbound.conf.j2
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Managed by cmdeploy: disable IPv6 in unbound.
|
||||||
|
server:
|
||||||
|
interface: 127.0.0.1
|
||||||
|
do-ip6: no
|
||||||
@@ -80,6 +80,13 @@ steps. Please substitute it with your own domain.
|
|||||||
configure at your DNS provider (it can take some time until they are
|
configure at your DNS provider (it can take some time until they are
|
||||||
public).
|
public).
|
||||||
|
|
||||||
|
Docker installation
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
We have experimental support for `docker compose <https://github.com/chatmail/relay/blob/docker-rebase/docs/DOCKER_INSTALLATION_EN.md>`_,
|
||||||
|
but it is not covered by automated tests yet,
|
||||||
|
so don't expect everything to work.
|
||||||
|
|
||||||
Other helpful commands
|
Other helpful commands
|
||||||
----------------------
|
----------------------
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ The deployed system components of a chatmail relay are:
|
|||||||
- Dovecot_ is the Mail Delivery Agent (MDA) and
|
- Dovecot_ is the Mail Delivery Agent (MDA) and
|
||||||
stores messages for users until they download them
|
stores messages for users until they download them
|
||||||
|
|
||||||
|
- `filtermail <https://github.com/chatmail/filtermail>`_
|
||||||
|
prevents unencrypted email from leaving or entering the chatmail
|
||||||
|
service and is integrated into Postfix’s outbound and inbound mail
|
||||||
|
pipelines.
|
||||||
|
|
||||||
- Nginx_ shows the web page with privacy policy and additional information
|
- Nginx_ shows the web page with privacy policy and additional information
|
||||||
|
|
||||||
- `acmetool <https://hlandau.github.io/acmetool/>`_ manages TLS
|
- `acmetool <https://hlandau.github.io/acmetool/>`_ manages TLS
|
||||||
@@ -85,11 +90,6 @@ short overview of ``chatmaild`` services:
|
|||||||
<https://doc.dovecot.org/2.3/configuration_manual/authentication/dict/#complete-example-for-authenticating-via-a-unix-socket>`_
|
<https://doc.dovecot.org/2.3/configuration_manual/authentication/dict/#complete-example-for-authenticating-via-a-unix-socket>`_
|
||||||
to authenticate logins.
|
to authenticate logins.
|
||||||
|
|
||||||
- `filtermail <https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/filtermail.py>`_
|
|
||||||
prevents unencrypted email from leaving or entering the chatmail
|
|
||||||
service and is integrated into Postfix’s outbound and inbound mail
|
|
||||||
pipelines.
|
|
||||||
|
|
||||||
- `chatmail-metadata <https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/metadata.py>`_
|
- `chatmail-metadata <https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/metadata.py>`_
|
||||||
is contacted by a `Dovecot lua
|
is contacted by a `Dovecot lua
|
||||||
script <https://github.com/chatmail/relay/blob/main/cmdeploy/src/cmdeploy/dovecot/push_notification.lua>`_
|
script <https://github.com/chatmail/relay/blob/main/cmdeploy/src/cmdeploy/dovecot/push_notification.lua>`_
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ We know of three work-in-progress alternative implementation efforts:
|
|||||||
it to support all of the features and configuration settings required
|
it to support all of the features and configuration settings required
|
||||||
to operate as a chatmail relay.
|
to operate as a chatmail relay.
|
||||||
|
|
||||||
- `Madmail <https://github.com/omidz4t/madmail>`_: an
|
- `Madmail <https://github.com/themadorg/madmail>`_: an
|
||||||
experimental fork of Maddy Mail Server <https://maddy.email/>`_ optimized
|
experimental fork of `Maddy Mail Server <https://maddy.email/>`_, modified
|
||||||
for chatmail deployments. It provides a single binary solution
|
for chatmail deployments. It provides a single binary solution
|
||||||
for running a chatmail relay.
|
for running a chatmail relay.
|
||||||
|
|
||||||
|
|||||||
52
docker-compose.yaml
Normal file
52
docker-compose.yaml
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
services:
|
||||||
|
chatmail:
|
||||||
|
build:
|
||||||
|
context: ./
|
||||||
|
dockerfile: docker/chatmail_relay.dockerfile
|
||||||
|
image: chatmail-relay:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
container_name: chatmail
|
||||||
|
# Required for systemd — use only one of the following:
|
||||||
|
cgroup: host # compose v2 only
|
||||||
|
# privileged: true # compose v1 (not tested)
|
||||||
|
tty: true # required for logs
|
||||||
|
tmpfs: # required for systemd
|
||||||
|
- /tmp
|
||||||
|
- /run
|
||||||
|
- /run/lock
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
environment:
|
||||||
|
CHANGE_KERNEL_SETTINGS: "False"
|
||||||
|
MAIL_DOMAIN: $MAIL_DOMAIN
|
||||||
|
ACME_EMAIL: $ACME_EMAIL
|
||||||
|
RECREATE_VENV: $RECREATE_VENV
|
||||||
|
MAX_MESSAGE_SIZE: $MAX_MESSAGE_SIZE
|
||||||
|
DEBUG_COMMANDS_ENABLED: $DEBUG_COMMANDS_ENABLED
|
||||||
|
FORCE_REINIT_INI_FILE: $FORCE_REINIT_INI_FILE
|
||||||
|
USE_FOREIGN_CERT_MANAGER: $USE_FOREIGN_CERT_MANAGER
|
||||||
|
ENABLE_CERTS_MONITORING: $ENABLE_CERTS_MONITORING
|
||||||
|
CERTS_MONITORING_TIMEOUT: $CERTS_MONITORING_TIMEOUT
|
||||||
|
IS_DEVELOPMENT_INSTANCE: $IS_DEVELOPMENT_INSTANCE
|
||||||
|
CMDEPLOY_STAGES: ${CMDEPLOY_STAGES:-}
|
||||||
|
network_mode: "host"
|
||||||
|
volumes:
|
||||||
|
## system
|
||||||
|
- /sys/fs/cgroup:/sys/fs/cgroup:rw # required for systemd
|
||||||
|
- ./:/opt/chatmail
|
||||||
|
|
||||||
|
## data
|
||||||
|
- ./data/chatmail:/home
|
||||||
|
- ./data/chatmail-dkimkeys:/etc/dkimkeys
|
||||||
|
- ./data/chatmail-acme:/var/lib/acme
|
||||||
|
|
||||||
|
## custom resources
|
||||||
|
# - ./custom/www/src/index.md:/opt/chatmail/www/src/index.md
|
||||||
|
|
||||||
|
## debug
|
||||||
|
# - ./docker/files/setup_chatmail_docker.sh:/setup_chatmail_docker.sh
|
||||||
|
# - ./docker/files/entrypoint.sh:/entrypoint.sh
|
||||||
|
# - ./docker/files/update_ini.sh:/update_ini.sh
|
||||||
100
docker/chatmail_relay.dockerfile
Normal file
100
docker/chatmail_relay.dockerfile
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
FROM jrei/systemd-debian:12 AS base
|
||||||
|
|
||||||
|
ENV LANG=en_US.UTF-8
|
||||||
|
|
||||||
|
RUN echo 'APT::Install-Recommends "0";' > /etc/apt/apt.conf.d/01norecommend && \
|
||||||
|
echo 'APT::Install-Suggests "0";' >> /etc/apt/apt.conf.d/01norecommend && \
|
||||||
|
apt-get update && \
|
||||||
|
apt-get install -y \
|
||||||
|
ca-certificates && \
|
||||||
|
DEBIAN_FRONTEND=noninteractive \
|
||||||
|
TZ=Europe/London \
|
||||||
|
apt-get install -y tzdata && \
|
||||||
|
apt-get install -y locales && \
|
||||||
|
sed -i -e "s/# $LANG.*/$LANG UTF-8/" /etc/locale.gen && \
|
||||||
|
dpkg-reconfigure --frontend=noninteractive locales && \
|
||||||
|
update-locale LANG=$LANG \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y \
|
||||||
|
git \
|
||||||
|
python3 \
|
||||||
|
python3-venv \
|
||||||
|
python3-virtualenv \
|
||||||
|
gcc \
|
||||||
|
python3-dev \
|
||||||
|
opendkim \
|
||||||
|
opendkim-tools \
|
||||||
|
curl \
|
||||||
|
rsync \
|
||||||
|
unbound \
|
||||||
|
unbound-anchor \
|
||||||
|
dnsutils \
|
||||||
|
postfix \
|
||||||
|
acl \
|
||||||
|
nginx \
|
||||||
|
libnginx-mod-stream \
|
||||||
|
fcgiwrap \
|
||||||
|
cron \
|
||||||
|
&& for pkg in core imapd lmtpd; do \
|
||||||
|
case "$pkg" in \
|
||||||
|
core) sha256="43f593332e22ac7701c62d58b575d2ca409e0f64857a2803be886c22860f5587" ;; \
|
||||||
|
imapd) sha256="8d8dc6fc00bbb6cdb25d345844f41ce2f1c53f764b79a838eb2a03103eebfa86" ;; \
|
||||||
|
lmtpd) sha256="2f69ba5e35363de50962d42cccbfe4ed8495265044e244007d7ccddad77513ab" ;; \
|
||||||
|
esac; \
|
||||||
|
url="https://download.delta.chat/dovecot/dovecot-${pkg}_2.3.21%2Bdfsg1-3_amd64.deb"; \
|
||||||
|
file="/tmp/$(basename "$url")"; \
|
||||||
|
curl -fsSL "$url" -o "$file"; \
|
||||||
|
echo "$sha256 $file" | sha256sum -c -; \
|
||||||
|
apt-get install -y "$file"; \
|
||||||
|
rm -f "$file"; \
|
||||||
|
done \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /opt/chatmail
|
||||||
|
|
||||||
|
# --- Build-time install stage ---
|
||||||
|
# Bake the "install" deployer stage into the image; we can't use
|
||||||
|
# scripts/initenv.sh because /opt/chatmail is empty at build time as
|
||||||
|
# source arrives at runtime via volume mount., so we use a throwaway venv.
|
||||||
|
# On container start only "configure,activate" stages run.
|
||||||
|
COPY . /tmp/chatmail-src/
|
||||||
|
WORKDIR /tmp/chatmail-src
|
||||||
|
|
||||||
|
# Dummy config — deploy_chatmail() needs a parseable ini to instantiate deployers
|
||||||
|
RUN printf '[params]\nmail_domain = build.local\n' > /tmp/chatmail.ini
|
||||||
|
|
||||||
|
# Do what initenv.sh would do without the docs
|
||||||
|
RUN python3 -m venv /tmp/build-venv && \
|
||||||
|
/tmp/build-venv/bin/pip install --no-cache-dir \
|
||||||
|
-e chatmaild -e cmdeploy
|
||||||
|
|
||||||
|
RUN CMDEPLOY_STAGES=install \
|
||||||
|
CHATMAIL_INI=/tmp/chatmail.ini \
|
||||||
|
CHATMAIL_DOCKER=True \
|
||||||
|
/tmp/build-venv/bin/pyinfra @local \
|
||||||
|
/tmp/chatmail-src/cmdeploy/src/cmdeploy/run.py -y
|
||||||
|
|
||||||
|
RUN rm -rf /tmp/chatmail-src /tmp/build-venv /tmp/chatmail.ini
|
||||||
|
|
||||||
|
WORKDIR /opt/chatmail
|
||||||
|
# --- End build-time install stage ---
|
||||||
|
|
||||||
|
ARG SETUP_CHATMAIL_SERVICE_PATH=/lib/systemd/system/setup_chatmail.service
|
||||||
|
COPY ./docker/files/setup_chatmail.service "$SETUP_CHATMAIL_SERVICE_PATH"
|
||||||
|
RUN ln -sf "$SETUP_CHATMAIL_SERVICE_PATH" "/etc/systemd/system/multi-user.target.wants/setup_chatmail.service"
|
||||||
|
|
||||||
|
COPY --chmod=555 ./docker/files/setup_chatmail_docker.sh /setup_chatmail_docker.sh
|
||||||
|
COPY --chmod=555 ./docker/files/update_ini.sh /update_ini.sh
|
||||||
|
COPY --chmod=555 ./docker/files/entrypoint.sh /entrypoint.sh
|
||||||
|
|
||||||
|
VOLUME ["/sys/fs/cgroup", "/home"]
|
||||||
|
|
||||||
|
STOPSIGNAL SIGRTMIN+3
|
||||||
|
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
|
||||||
|
CMD [ "--default-standard-output=journal+console", \
|
||||||
|
"--default-standard-error=journal+console" ]
|
||||||
|
|
||||||
84
docker/cm_ini_to_env.py
Executable file
84
docker/cm_ini_to_env.py
Executable file
@@ -0,0 +1,84 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Convert a chatmail.ini to a Docker .env file.
|
||||||
|
|
||||||
|
Usage: python docker/cm_ini_to_env.py [chatmail.ini] [.env]
|
||||||
|
|
||||||
|
Reads the ini file, extracts all non-default key=value pairs,
|
||||||
|
and writes them as UPPER_CASE env vars suitable for docker-compose.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import configparser
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Keys that only make sense for bare-metal deploys or are handled
|
||||||
|
# separately by the Docker setup and should not appear in .env.
|
||||||
|
SKIP_KEYS = set()
|
||||||
|
|
||||||
|
# Keys that exist in .env but have a different name than the ini key.
|
||||||
|
# ini_key -> env_key
|
||||||
|
RENAMES = {}
|
||||||
|
|
||||||
|
|
||||||
|
def read_ini(path):
|
||||||
|
"""Return dict of key=value from [params] section."""
|
||||||
|
cp = configparser.ConfigParser()
|
||||||
|
cp.read(path)
|
||||||
|
if not cp.has_section("params"):
|
||||||
|
sys.exit(f"Error: {path} has no [params] section")
|
||||||
|
return dict(cp.items("params"))
|
||||||
|
|
||||||
|
|
||||||
|
def read_defaults():
|
||||||
|
"""Return dict of default values from the ini template."""
|
||||||
|
template = Path(__file__).resolve().parent.parent / "chatmaild/src/chatmaild/ini/chatmail.ini.f"
|
||||||
|
if not template.exists():
|
||||||
|
return {}
|
||||||
|
cp = configparser.ConfigParser()
|
||||||
|
cp.read(template)
|
||||||
|
if not cp.has_section("params"):
|
||||||
|
return {}
|
||||||
|
defaults = {}
|
||||||
|
for key, value in cp.items("params"):
|
||||||
|
# Template placeholders like {mail_domain} aren't real defaults.
|
||||||
|
if "{" not in value:
|
||||||
|
defaults[key] = value
|
||||||
|
return defaults
|
||||||
|
|
||||||
|
|
||||||
|
def ini_to_env(ini_path, only_non_default=True):
|
||||||
|
"""Yield (ENV_KEY, value) pairs from an ini file."""
|
||||||
|
params = read_ini(ini_path)
|
||||||
|
defaults = read_defaults() if only_non_default else {}
|
||||||
|
|
||||||
|
for key, value in sorted(params.items()):
|
||||||
|
if key in SKIP_KEYS:
|
||||||
|
continue
|
||||||
|
if only_non_default and key in defaults and value.strip() == defaults[key].strip():
|
||||||
|
continue
|
||||||
|
env_key = RENAMES.get(key, key.upper())
|
||||||
|
yield env_key, value.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ini_path = sys.argv[1] if len(sys.argv) > 1 else "chatmail.ini"
|
||||||
|
env_path = sys.argv[2] if len(sys.argv) > 2 else None
|
||||||
|
|
||||||
|
if not Path(ini_path).exists():
|
||||||
|
sys.exit(f"Error: {ini_path} not found")
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for env_key, value in ini_to_env(ini_path):
|
||||||
|
lines.append(f'{env_key}="{value}"')
|
||||||
|
|
||||||
|
output = "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
if env_path:
|
||||||
|
Path(env_path).write_text(output)
|
||||||
|
print(f"Wrote {len(lines)} variables to {env_path}")
|
||||||
|
else:
|
||||||
|
print(output, end="")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
11
docker/example.env
Normal file
11
docker/example.env
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
MAIL_DOMAIN="chat.example.com"
|
||||||
|
# ACME_EMAIL=""
|
||||||
|
# RECREATE_VENV="false"
|
||||||
|
# MAX_MESSAGE_SIZE="50M"
|
||||||
|
# DEBUG_COMMANDS_ENABLED="true"
|
||||||
|
# FORCE_REINIT_INI_FILE="true"
|
||||||
|
# USE_FOREIGN_CERT_MANAGER="True"
|
||||||
|
# ENABLE_CERTS_MONITORING="true"
|
||||||
|
# CERTS_MONITORING_TIMEOUT=10
|
||||||
|
# IS_DEVELOPMENT_INSTANCE="True"
|
||||||
|
# CMDEPLOY_STAGES - default: "configure,activate". Set to "install,configure,activate" to force full reinstall.
|
||||||
11
docker/files/entrypoint.sh
Executable file
11
docker/files/entrypoint.sh
Executable file
@@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -eo pipefail
|
||||||
|
|
||||||
|
unlink /etc/nginx/sites-enabled/default || true
|
||||||
|
|
||||||
|
SETUP_CHATMAIL_SERVICE_PATH="${SETUP_CHATMAIL_SERVICE_PATH:-/lib/systemd/system/setup_chatmail.service}"
|
||||||
|
|
||||||
|
env_vars=$(printenv | cut -d= -f1 | xargs)
|
||||||
|
sed -i "s|<envs_list>|$env_vars|g" $SETUP_CHATMAIL_SERVICE_PATH
|
||||||
|
|
||||||
|
exec /lib/systemd/systemd $@
|
||||||
14
docker/files/setup_chatmail.service
Normal file
14
docker/files/setup_chatmail.service
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Run container setup commands
|
||||||
|
After=multi-user.target
|
||||||
|
ConditionPathExists=/setup_chatmail_docker.sh
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/bin/bash /setup_chatmail_docker.sh
|
||||||
|
RemainAfterExit=true
|
||||||
|
WorkingDirectory=/opt/chatmail
|
||||||
|
PassEnvironment=<envs_list>
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
84
docker/files/setup_chatmail_docker.sh
Executable file
84
docker/files/setup_chatmail_docker.sh
Executable file
@@ -0,0 +1,84 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -eo pipefail
|
||||||
|
export INI_FILE="${INI_FILE:-chatmail.ini}"
|
||||||
|
export ENABLE_CERTS_MONITORING="${ENABLE_CERTS_MONITORING:-true}"
|
||||||
|
export CERTS_MONITORING_TIMEOUT="${CERTS_MONITORING_TIMEOUT:-60}"
|
||||||
|
export PATH_TO_SSL="${PATH_TO_SSL:-/var/lib/acme/live/${MAIL_DOMAIN}}"
|
||||||
|
export CHANGE_KERNEL_SETTINGS=${CHANGE_KERNEL_SETTINGS:-"False"}
|
||||||
|
export RECREATE_VENV=${RECREATE_VENV:-"false"}
|
||||||
|
|
||||||
|
if [ -z "$MAIL_DOMAIN" ]; then
|
||||||
|
echo "ERROR: Environment variable 'MAIL_DOMAIN' must be set!" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
debug_commands() {
|
||||||
|
echo "Executing debug commands"
|
||||||
|
# git config --global --add safe.directory /opt/chatmail
|
||||||
|
# ./scripts/initenv.sh
|
||||||
|
}
|
||||||
|
|
||||||
|
calculate_hash() {
|
||||||
|
find "$PATH_TO_SSL" -type f -exec sha1sum {} \; | sort | sha1sum | awk '{print $1}'
|
||||||
|
}
|
||||||
|
|
||||||
|
monitor_certificates() {
|
||||||
|
if [ "$ENABLE_CERTS_MONITORING" != "true" ]; then
|
||||||
|
echo "Certs monitoring disabled."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
current_hash=$(calculate_hash)
|
||||||
|
previous_hash=$current_hash
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
current_hash=$(calculate_hash)
|
||||||
|
if [[ "$current_hash" != "$previous_hash" ]]; then
|
||||||
|
# TODO: add an option to restart at a specific time interval
|
||||||
|
echo "[INFO] Certificate's folder hash was changed, reloading nginx, dovecot and postfix services."
|
||||||
|
systemctl reload nginx.service
|
||||||
|
systemctl reload dovecot.service
|
||||||
|
systemctl reload postfix.service
|
||||||
|
previous_hash=$current_hash
|
||||||
|
fi
|
||||||
|
sleep $CERTS_MONITORING_TIMEOUT
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
### MAIN
|
||||||
|
|
||||||
|
if [ "$DEBUG_COMMANDS_ENABLED" = true ]; then
|
||||||
|
debug_commands
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$FORCE_REINIT_INI_FILE" = true ]; then
|
||||||
|
INI_CMD_ARGS=--force
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f /etc/dkimkeys/opendkim.private ]; then
|
||||||
|
/usr/sbin/opendkim-genkey -D /etc/dkimkeys -d $MAIL_DOMAIN -s opendkim
|
||||||
|
fi
|
||||||
|
chown opendkim:opendkim /etc/dkimkeys/opendkim.private
|
||||||
|
chown opendkim:opendkim /etc/dkimkeys/opendkim.txt
|
||||||
|
|
||||||
|
# TODO: Move to debug_commands after git clone is moved to dockerfile.
|
||||||
|
git config --global --add safe.directory /opt/chatmail
|
||||||
|
if [ "$RECREATE_VENV" = true ]; then
|
||||||
|
rm -rf venv
|
||||||
|
fi
|
||||||
|
# Skip venv creation if it already exists
|
||||||
|
if [ ! -x venv/bin/python ] || [ ! -x venv/bin/cmdeploy ]; then
|
||||||
|
./scripts/initenv.sh
|
||||||
|
fi
|
||||||
|
|
||||||
|
./scripts/cmdeploy init --config "${INI_FILE}" $INI_CMD_ARGS $MAIL_DOMAIN || true
|
||||||
|
bash /update_ini.sh
|
||||||
|
|
||||||
|
export CMDEPLOY_STAGES="${CMDEPLOY_STAGES:-configure,activate}"
|
||||||
|
./scripts/cmdeploy run --ssh-host @docker
|
||||||
|
|
||||||
|
echo "ForwardToConsole=yes" >> /etc/systemd/journald.conf
|
||||||
|
systemctl restart systemd-journald
|
||||||
|
|
||||||
|
monitor_certificates &
|
||||||
79
docker/files/update_ini.sh
Normal file
79
docker/files/update_ini.sh
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -eo pipefail
|
||||||
|
|
||||||
|
INI_FILE="${INI_FILE:-chatmail.ini}"
|
||||||
|
|
||||||
|
if [ ! -f "$INI_FILE" ]; then
|
||||||
|
echo "Error: file $INI_FILE not found." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
TMP_FILE="$(mktemp)"
|
||||||
|
|
||||||
|
convert_to_bytes() {
|
||||||
|
local value="$1"
|
||||||
|
if [[ "$value" =~ ^([0-9]+)([KkMmGgTt])$ ]]; then
|
||||||
|
local num="${BASH_REMATCH[1]}"
|
||||||
|
local unit="${BASH_REMATCH[2]}"
|
||||||
|
case "$unit" in
|
||||||
|
[Kk]) echo $((num * 1024)) ;;
|
||||||
|
[Mm]) echo $((num * 1024 * 1024)) ;;
|
||||||
|
[Gg]) echo $((num * 1024 * 1024 * 1024)) ;;
|
||||||
|
[Tt]) echo $((num * 1024 * 1024 * 1024 * 1024)) ;;
|
||||||
|
esac
|
||||||
|
elif [[ "$value" =~ ^[0-9]+$ ]]; then
|
||||||
|
echo "$value"
|
||||||
|
else
|
||||||
|
echo "Error: incorrect size format: $value." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
process_specific_params() {
|
||||||
|
local key=$1
|
||||||
|
local value=$2
|
||||||
|
local destination_file=$3
|
||||||
|
|
||||||
|
if [[ "$key" == "max_message_size" ]]; then
|
||||||
|
converted=$(convert_to_bytes "$value") || exit 1
|
||||||
|
if grep -q -e "## .* = .* bytes" "$destination_file"; then
|
||||||
|
sed "s|## .* = .* bytes|## $value = $converted bytes|g" "$destination_file";
|
||||||
|
else
|
||||||
|
echo "## $value = $converted bytes" >> "$destination_file"
|
||||||
|
fi
|
||||||
|
echo "$key = $converted" >> "$destination_file"
|
||||||
|
else
|
||||||
|
echo "$key = $value" >> "$destination_file"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
while IFS= read -r line; do
|
||||||
|
if [[ "$line" =~ ^[[:space:]]*#.* || "$line" =~ ^[[:space:]]*$ ]]; then
|
||||||
|
echo "$line" >> "$TMP_FILE"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$line" =~ ^([a-z0-9_]+)[[:space:]]*=[[:space:]]*(.*)$ ]]; then
|
||||||
|
key="${BASH_REMATCH[1]}"
|
||||||
|
current_value="${BASH_REMATCH[2]}"
|
||||||
|
env_var_name=$(echo "$key" | tr 'a-z' 'A-Z')
|
||||||
|
env_value="${!env_var_name}"
|
||||||
|
|
||||||
|
if [[ -n "$env_value" ]]; then
|
||||||
|
process_specific_params "$key" "$env_value" "$TMP_FILE"
|
||||||
|
else
|
||||||
|
echo "$line" >> "$TMP_FILE"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "$line" >> "$TMP_FILE"
|
||||||
|
fi
|
||||||
|
done < "$INI_FILE"
|
||||||
|
|
||||||
|
PERMS=$(stat -c %a "$INI_FILE")
|
||||||
|
OWNER=$(stat -c %u "$INI_FILE")
|
||||||
|
GROUP=$(stat -c %g "$INI_FILE")
|
||||||
|
|
||||||
|
chmod "$PERMS" "$TMP_FILE"
|
||||||
|
chown "$OWNER":"$GROUP" "$TMP_FILE"
|
||||||
|
|
||||||
|
mv "$TMP_FILE" "$INI_FILE"
|
||||||
185
docs/DOCKER_INSTALLATION_EN.md
Normal file
185
docs/DOCKER_INSTALLATION_EN.md
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
# Known issues and limitations
|
||||||
|
|
||||||
|
- Requires cgroups v2 configured in the system. Operation with cgroups v1 has not been tested.
|
||||||
|
- Yes, of course, using systemd inside a container is a hack, and it would be better to split it into several services, but since this is an MVP, it turned out to be easier to do it this way initially than to rewrite the entire deployment system.
|
||||||
|
- The Docker image is only suitable for amd64. If you need to run it on a different architecture, try modifying the Dockerfile (specifically the part responsible for installing dovecot).
|
||||||
|
|
||||||
|
# Docker installation
|
||||||
|
This section provides instructions for installing Chatmail using Docker Compose.
|
||||||
|
|
||||||
|
**Note:** Docker Compose v2 is required (`docker compose`, not `docker-compose`) for its support of the `cgroup: host` option in `docker-compose.yaml` is only supported by Compose v2.
|
||||||
|
[see documentation](https://docs.docker.com/engine/install/debian/#install-using-the-repository)
|
||||||
|
```shell
|
||||||
|
apt install docker-ce docker-compose-plugin docker.io- docker-compose-
|
||||||
|
```
|
||||||
|
|
||||||
|
## Preliminary setup
|
||||||
|
We use `chat.example.org` as the Chatmail domain in the following steps.
|
||||||
|
Please substitute it with your own domain.
|
||||||
|
|
||||||
|
1. Setup the initial DNS records.
|
||||||
|
The following is an example in the familiar BIND zone file format with
|
||||||
|
a TTL of 1 hour (3600 seconds).
|
||||||
|
Please substitute your domain and IP addresses.
|
||||||
|
|
||||||
|
```
|
||||||
|
chat.example.com. 3600 IN A 198.51.100.5
|
||||||
|
chat.example.com. 3600 IN AAAA 2001:db8::5
|
||||||
|
www.chat.example.com. 3600 IN CNAME chat.example.com.
|
||||||
|
mta-sts.chat.example.com. 3600 IN CNAME chat.example.com.
|
||||||
|
```
|
||||||
|
|
||||||
|
2. clone the repository on your server.
|
||||||
|
|
||||||
|
```shell
|
||||||
|
git clone https://github.com/chatmail/relay
|
||||||
|
cd relay
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. Configure kernel parameters because they cannot be changed inside the container, specifically `fs.inotify.max_user_instances` and `fs.inotify.max_user_watches`. Run the following:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
echo "fs.inotify.max_user_instances=65536" | sudo tee -a /etc/sysctl.d/99-inotify.conf
|
||||||
|
echo "fs.inotify.max_user_watches=65536" | sudo tee -a /etc/sysctl.d/99-inotify.conf
|
||||||
|
sudo sysctl --system
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Copy `./docker/example.env` and rename it to `.env`. This file stores variables used in `docker-compose.yaml`.
|
||||||
|
|
||||||
|
```shell
|
||||||
|
cp ./docker/example.env .env
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Configure environment variables in the `.env` file. These variables are used in the `docker-compose.yaml` file to pass repeated values.
|
||||||
|
Below is the list of variables used during deployment:
|
||||||
|
|
||||||
|
- `MAIL_DOMAIN` – The domain name of the future server. (required)
|
||||||
|
- `DEBUG_COMMANDS_ENABLED` – Run debug commands before installation. (default: `false`)
|
||||||
|
- `FORCE_REINIT_INI_FILE` – Recreate the ini configuration file on startup. (default: `false`)
|
||||||
|
- `USE_FOREIGN_CERT_MANAGER` – Use a third-party certificate manager. (default: `false`)
|
||||||
|
- `RECREATE_VENV` - Recreate the virtual environment (venv). If set to `true`, the environment will be recreated when the container starts, which will increase the startup time of the service but can help avoid certain errors. (default: `false`)
|
||||||
|
- `INI_FILE` – Path to the ini configuration file. (default: `./chatmail.ini`)
|
||||||
|
- `PATH_TO_SSL` – Path to where the certificates are stored. (default: `/var/lib/acme/live/${MAIL_DOMAIN}`)
|
||||||
|
- `ENABLE_CERTS_MONITORING` – Enable certificate monitoring if `USE_FOREIGN_CERT_MANAGER=true`. If certificates change, services will be automatically restarted. (default: `false`)
|
||||||
|
- `CERTS_MONITORING_TIMEOUT` – Interval in seconds to check if certificates have changed. (default: `'60'`)
|
||||||
|
- `CMDEPLOY_STAGES` – Deployment stages to run on container start. (default: `"configure,activate"`). Set to `"install,configure,activate"` to force a full reinstall.
|
||||||
|
|
||||||
|
You can also use any variables from the [ini configuration file](https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/ini/chatmail.ini.f); they must be in uppercase.
|
||||||
|
|
||||||
|
4. Build the Docker image:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
docker compose build chatmail
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Start docker compose and wait for the installation to finish:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
docker compose up -d # start service
|
||||||
|
docker compose logs -f chatmail # view container logs, press CTRL+C to exit
|
||||||
|
```
|
||||||
|
|
||||||
|
### venv creation
|
||||||
|
The first container start takes longer because it creates the cmdeploy Python virtualenv at `/opt/chatmail/venv` (persisted on the host via volume mount). Subsequent starts reuse the existing venv. Set `RECREATE_VENV=true` in `.env` to force a rebuild if needed.
|
||||||
|
|
||||||
|
6. After installation is complete, you can open `https://<your_domain_name>` in your browser.
|
||||||
|
|
||||||
|
## Using custom files
|
||||||
|
|
||||||
|
When using Docker, you can apply modified configuration files to make the installation more personalized. This is usually needed for the `www/src` section so that the Chatmail landing page is customized to your taste, but it can be used for any other cases as well.
|
||||||
|
|
||||||
|
To replace files correctly:
|
||||||
|
|
||||||
|
1. Create the `./custom` directory. It is in `.gitignore`, so it won’t cause conflicts when updating.
|
||||||
|
|
||||||
|
```shell
|
||||||
|
mkdir -p ./custom
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Modify the required file. For example, `index.md`:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
mkdir -p ./custom/www/src
|
||||||
|
nano ./custom/www/src/index.md
|
||||||
|
```
|
||||||
|
|
||||||
|
3. In `docker-compose.yaml`, add the file mount in the `volumes` section:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
chatmail:
|
||||||
|
volumes:
|
||||||
|
...
|
||||||
|
## custom resources
|
||||||
|
- ./custom/www/src/index.md:/opt/chatmail/www/src/index.md
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Restart the service:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
docker compose down
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migrating from a bare-metal install
|
||||||
|
|
||||||
|
If you have an existing bare-metal Chatmail installation and want to switch to Docker:
|
||||||
|
|
||||||
|
1. Stop all existing services:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
systemctl stop postfix dovecot doveauth nginx opendkim unbound acmetool-redirector \
|
||||||
|
filtermail filtermail-incoming chatmail-turn iroh-relay chatmail-metadata \
|
||||||
|
lastlogin mtail
|
||||||
|
systemctl disable postfix dovecot doveauth nginx opendkim unbound acmetool-redirector \
|
||||||
|
filtermail filtermail-incoming chatmail-turn iroh-relay chatmail-metadata \
|
||||||
|
lastlogin mtail
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Convert your existing `chatmail.ini` to the Docker `.env` format:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
python3 docker/cm_ini_to_env.py /usr/local/lib/chatmaild/chatmail.ini .env
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Copy persistent data into the `./data/` subdirectories:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
mkdir -p data/chatmail-dkimkeys data/chatmail-acme data/chatmail
|
||||||
|
|
||||||
|
# DKIM keys
|
||||||
|
cp -a /etc/dkimkeys/* data/chatmail-dkimkeys/
|
||||||
|
|
||||||
|
# ACME certificates and account
|
||||||
|
rsync -a /var/lib/acme/ data/chatmail-acme/
|
||||||
|
|
||||||
|
# Mail data
|
||||||
|
rsync -a /home/ data/chatmail/
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternatively, you can mount `/home/vmail` directly by changing the volume in `docker-compose.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- /home/vmail:/home/vmail
|
||||||
|
```
|
||||||
|
|
||||||
|
The three `./data/` subdirectories cover all persistent state. Everything else is regenerated by the `configure` and `activate` stages on container start.
|
||||||
|
|
||||||
|
## Forcing a full reinstall
|
||||||
|
|
||||||
|
The Docker image bakes the install stage (binary downloads, package setup, chatmaild venv) into the image at build time. On container start, only the `configure` and `activate` stages run by default.
|
||||||
|
|
||||||
|
To force a full reinstall (e.g., after updating the source), either rebuild the image:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
docker compose build chatmail
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Or override the stages at runtime without rebuilding:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
CMDEPLOY_STAGES="install,configure,activate" docker compose up -d
|
||||||
|
```
|
||||||
174
docs/DOCKER_INSTALLATION_RU.md
Normal file
174
docs/DOCKER_INSTALLATION_RU.md
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
# Известные проблемы и ограничения
|
||||||
|
- Chatmail будет переустановлен при каждом запуске контейнера (при первом - долго, при последующих быстрее). Так устроен изначальный установщик, потому что он не был заточен под docker. В конце документации [представлено](#фиксирование-версии-chatmail) возможное решение
|
||||||
|
- Требуется настроенный в системе cgroups v2. Работа с cgroups v1 не тестировалась.
|
||||||
|
- Да, понятно дело что systemd использовать в контейнере костыль и надо это всё разнести на несколько сервисов, но это MVP и в первом приближении оказалось сделать проще так, чем переписывать всю систему развертывания.
|
||||||
|
- docker образ подходит только для amd64, если нужно запустить на другой архитектуре, попробуйте изменить dockerfile (конкретно ту часть что ответсвенна за установку dovecot)
|
||||||
|
|
||||||
|
# Docker installation
|
||||||
|
Здесь представлена инструкция по установке chatmail с помощью docker-compose.
|
||||||
|
|
||||||
|
## Предварительная настройка
|
||||||
|
We use `chat.example.org` as the chatmail domain in the following steps.
|
||||||
|
Please substitute it with your own domain.
|
||||||
|
|
||||||
|
1. Настройте начальные записи DNS.Ниже приведен пример в привычном формате файла зоны BIND сTTL 1 час (3600 секунд).
|
||||||
|
Замените домен и IP-адреса на свои.
|
||||||
|
|
||||||
|
```
|
||||||
|
chat.example.com. 3600 IN A 198.51.100.5
|
||||||
|
chat.example.com. 3600 IN AAAA 2001:db8::5
|
||||||
|
www.chat.example.com. 3600 IN CNAME chat.example.com.
|
||||||
|
mta-sts.chat.example.com. 3600 IN CNAME chat.example.com.
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Склонируйте репозиторий на свой сервер.
|
||||||
|
|
||||||
|
```shell
|
||||||
|
git clone https://github.com/chatmail/relay
|
||||||
|
cd relay
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. Настроить параметры ядра, потому что внутри контейнера их нельзя изменить, а конкретно `fs.inotify.max_user_instances` и `fs.inotify.max_user_watches`. Для этого выполнить следующее:
|
||||||
|
```shell
|
||||||
|
echo "fs.inotify.max_user_instances=65536" | sudo tee -a /etc/sysctl.d/99-inotify.conf
|
||||||
|
echo "fs.inotify.max_user_watches=65536" | sudo tee -a /etc/sysctl.d/99-inotify.conf
|
||||||
|
sudo sysctl --system
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Скопировать `./docker/example.env` и переименовать в `.env`. Здесь хранятся переменные, которые используются в `docker-compose.yaml`.
|
||||||
|
```shell
|
||||||
|
cp ./docker/example.env .env
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Настроить переменные окружения в `.env` файле. Эти переменные используются в `docker-compose.yaml` файле, чтобы передавать повторяющиеся значения.
|
||||||
|
Ниже перечислен список переменных учавствующих при развертывании:
|
||||||
|
|
||||||
|
- `MAIL_DOMAIN` - Доменное имя будущего сервера. (required)
|
||||||
|
- `DEBUG_COMMANDS_ENABLED` - Выполнить debug команды перед установкой. (default: `false`)
|
||||||
|
- `FORCE_REINIT_INI_FILE` - Пересоздавать ini файл конфигурации при запуске. (default: `false`)
|
||||||
|
- `USE_FOREIGN_CERT_MANAGER` - Использовать сторонний менеджер сертификатов. (default: `false`)
|
||||||
|
- `RECREATE_VENV` - Пересоздать виртуальное окружение (venv). Если выставлено `true`, то окружение будет пересоздано при запуске контейнера, из-за чего включение сервиса займет больше времени, но поможет избежать ряда ошибок. (default: `false`)
|
||||||
|
- `INI_FILE` - путь к ini файлу конфигурации. (default: `./chatmail.ini`)
|
||||||
|
- `PATH_TO_SSL` - Путь где располагаются сертификаты. (default: `/var/lib/acme/live/${MAIL_DOMAIN}`)
|
||||||
|
- `ENABLE_CERTS_MONITORING` - Включить мониторинг сертификатов, если `USE_FOREIGN_CERT_MANAGER=true`. Если сертфикаты изменятся сервисы будут автоматически перезапущены. (default: `false`)
|
||||||
|
- `CERTS_MONITORING_TIMEOUT` - Раз во сколько секунд проверять что изменились сертификаты. (default: `'60'`)
|
||||||
|
|
||||||
|
Также могут быть использованы все переменные из [ini файла конфигурации](https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/ini/chatmail.ini.f), они обязаны быть в uppercase формате.
|
||||||
|
|
||||||
|
4. Собрать docker образ
|
||||||
|
```shell
|
||||||
|
docker compose build chatmail
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Запустить docker compose и дождаться завершения установки
|
||||||
|
```shell
|
||||||
|
docker compose up -d # запуск сервиса
|
||||||
|
docker compose logs -f chatmail # просмотр логов контейнера. Для выхода нажать CTRL+C
|
||||||
|
```
|
||||||
|
|
||||||
|
6. По окончанию установки можно открыть в браузер `https://<your_domain_name>`
|
||||||
|
|
||||||
|
## Использование кастомных файлов
|
||||||
|
При использовании docker есть возможность использовать измененые файлы конфигурации, чтобы сделать установку более персонализированной. Обычно это требуется для секции `www/src`, чтобы ознакомительная страница Chatmail была сделана на ваш вкус. Но также это можно использовать и для любых других случаев.
|
||||||
|
|
||||||
|
Для того чтобы корректно выполнить подмену файлов необходимо
|
||||||
|
1. создать каталог `./custom`, он находится в `.gitignore`, поэтому при обновлении не вызовет конфликтов.
|
||||||
|
```shell
|
||||||
|
mkdir -p ./custom
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Изменить нужный файл. Для примера возьмем `index.md`
|
||||||
|
```shell
|
||||||
|
mkdir -p ./custom/www/src
|
||||||
|
nano ./custom/www/src/index.md
|
||||||
|
```
|
||||||
|
|
||||||
|
3. В `docker-compose.yaml` добавить монтирование файла с помощью секции `volumes`
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
chatmail:
|
||||||
|
volumes:
|
||||||
|
...
|
||||||
|
## custom resources
|
||||||
|
- ./custom/www/src/index.md:/opt/chatmail/www/src/index.md
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Перезапустить сервис
|
||||||
|
```shell
|
||||||
|
docker compose down
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Фиксирование версии Chatmail
|
||||||
|
> [!note]
|
||||||
|
> Это опциональные шаги, их делать требуется только если вас не устраивает что сервис устанавливается каждый раз при запуске
|
||||||
|
|
||||||
|
Поскольку в текущей версии docker chatmail сервис устанавливается каждый раз запуске контейнера, чтобы этого не происходило можно зафиксировать версию контейнера после установки. Делается это следующим образом:
|
||||||
|
|
||||||
|
1. Зафиксировать текущее состояние сконфигурированного контейнера
|
||||||
|
```shell
|
||||||
|
docker container commit chatmail configured-chatmail:$(date +'%Y-%m-%d')
|
||||||
|
docker image ls | grep configured-chatmail
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Изменить entrypoint для контейнера в `docker-compose.yaml` на
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
chatmail:
|
||||||
|
image: <image name from step 1>
|
||||||
|
volumes:
|
||||||
|
...
|
||||||
|
## custom resources
|
||||||
|
- ./custom/setup_chatmail_docker.sh:/setup_chatmail_docker.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Создать файл `./custom/setup_chatmail_docker.sh` с новым файлом конфигурации
|
||||||
|
```shell
|
||||||
|
mkdir -p ./custom
|
||||||
|
cat > ./custom/setup_chatmail_docker.sh << 'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -eo pipefail
|
||||||
|
|
||||||
|
export ENABLE_CERTS_MONITORING="${ENABLE_CERTS_MONITORING:-true}"
|
||||||
|
export CERTS_MONITORING_TIMEOUT="${CERTS_MONITORING_TIMEOUT:-60}"
|
||||||
|
export PATH_TO_SSL="${PATH_TO_SSL:-/var/lib/acme/live/${MAIL_DOMAIN}}"
|
||||||
|
|
||||||
|
calculate_hash() {
|
||||||
|
find "$PATH_TO_SSL" -type f -exec sha1sum {} \; | sort | sha1sum | awk '{print $1}'
|
||||||
|
}
|
||||||
|
|
||||||
|
monitor_certificates() {
|
||||||
|
if [ "$ENABLE_CERTS_MONITORING" != "true" ]; then
|
||||||
|
echo "Certs monitoring disabled."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
current_hash=$(calculate_hash)
|
||||||
|
previous_hash=$current_hash
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
current_hash=$(calculate_hash)
|
||||||
|
if [[ "$current_hash" != "$previous_hash" ]]; then
|
||||||
|
# TODO: add an option to restart at a specific time interval
|
||||||
|
echo "[INFO] Certificate's folder hash was changed, reloading nginx, dovecot and postfix services."
|
||||||
|
systemctl reload nginx.service
|
||||||
|
systemctl reload dovecot.service
|
||||||
|
systemctl reload postfix.service
|
||||||
|
previous_hash=$current_hash
|
||||||
|
fi
|
||||||
|
sleep $CERTS_MONITORING_TIMEOUT
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
monitor_certificates &
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Перезапустить сервис
|
||||||
|
```shell
|
||||||
|
docker compose down
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user