mirror of
https://github.com/chatmail/relay.git
synced 2026-05-10 16:04:37 +00:00
Compare commits
12 Commits
docker-reb
...
fix/multip
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d8e44a948 | ||
|
|
447c0ee33d | ||
|
|
47c9586b67 | ||
|
|
e32816b477 | ||
|
|
0d3cde9850 | ||
|
|
c48a7d80dc | ||
|
|
93eb996a42 | ||
|
|
0148ecde8e | ||
|
|
3ad8e5a6ee | ||
|
|
a5dc1d886d | ||
|
|
0443965f63 | ||
|
|
ea52fde2d9 |
@@ -1,7 +0,0 @@
|
||||
.git
|
||||
data/
|
||||
venv/
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.orig
|
||||
.pytest_cache
|
||||
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
with:
|
||||
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
|
||||
run: curl -L https://github.com/chatmail/filtermail/releases/download/v0.2.0/filtermail-x86_64-musl -o /usr/local/bin/filtermail && chmod +x /usr/local/bin/filtermail
|
||||
- name: run chatmaild tests
|
||||
working-directory: chatmaild
|
||||
run: pipx run tox
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -164,8 +164,3 @@ cython_debug/
|
||||
#.idea/
|
||||
|
||||
chatmail.zone
|
||||
|
||||
# docker
|
||||
/data/
|
||||
/custom/
|
||||
.env
|
||||
|
||||
@@ -121,13 +121,6 @@
|
||||
Provide an "fsreport" CLI for more fine grained analysis of message files.
|
||||
([#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,4 +1,3 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import iniconfig
|
||||
@@ -44,15 +43,7 @@ class Config:
|
||||
)
|
||||
self.mtail_address = params.get("mtail_address")
|
||||
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.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_compress = params.get("imap_compress", "false").lower() == "true"
|
||||
if "iroh_relay" not in params:
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
import filelock
|
||||
|
||||
try:
|
||||
import crypt_r
|
||||
except ImportError:
|
||||
@@ -13,6 +16,7 @@ from .dictproxy import DictProxy
|
||||
from .migrate_db import migrate_from_db_to_maildir
|
||||
|
||||
NOCREATE_FILE = "/etc/chatmail-nocreate"
|
||||
VALID_LOCALPART_RE = re.compile(r"^[a-z0-9._-]+$")
|
||||
|
||||
|
||||
def encrypt_password(password: str):
|
||||
@@ -52,6 +56,10 @@ def is_allowed_to_create(config: Config, user, cleartext_password) -> bool:
|
||||
)
|
||||
return False
|
||||
|
||||
if not VALID_LOCALPART_RE.match(localpart):
|
||||
logging.warning("localpart %r contains invalid characters", localpart)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -140,8 +148,13 @@ class AuthDictProxy(DictProxy):
|
||||
if not is_allowed_to_create(self.config, addr, cleartext_password):
|
||||
return
|
||||
|
||||
user.set_password(encrypt_password(cleartext_password))
|
||||
print(f"Created address: {addr}", file=sys.stderr)
|
||||
lock = filelock.FileLock(str(user.password_path) + ".lock", timeout=5)
|
||||
with lock:
|
||||
userdata = user.get_userdb_dict()
|
||||
if userdata:
|
||||
return userdata
|
||||
user.set_password(encrypt_password(cleartext_password))
|
||||
print(f"Created address: {addr}", file=sys.stderr)
|
||||
return user.get_userdb_dict()
|
||||
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ class Report:
|
||||
for size in self.message_buckets:
|
||||
for msg in mailbox.messages:
|
||||
if msg.size >= size:
|
||||
if self.mdir and not msg.relpath.startswith(self.mdir):
|
||||
if self.mdir and f"/{self.mdir}/" not in msg.path:
|
||||
continue
|
||||
self.message_buckets[size] += msg.size
|
||||
|
||||
|
||||
@@ -69,16 +69,6 @@ disable_ipv6 = False
|
||||
# Your email adress, which will be used in acmetool to manage Let's Encrypt SSL certificates
|
||||
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
|
||||
# service.
|
||||
# If you set it to anything else, the service will be disabled
|
||||
|
||||
@@ -101,7 +101,11 @@ class MetadataDictProxy(DictProxy):
|
||||
# Handle `GETMETADATA "" /shared/vendor/deltachat/irohrelay`
|
||||
return f"O{self.iroh_relay}\n"
|
||||
elif keyname == "vendor/vendor.dovecot/pvt/server/vendor/deltachat/turn":
|
||||
res = turn_credentials()
|
||||
try:
|
||||
res = turn_credentials()
|
||||
except Exception:
|
||||
logging.exception("failed to get TURN credentials")
|
||||
return "N\n"
|
||||
port = 3478
|
||||
return f"O{self.turn_hostname}:{port}:{res}\n"
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"""CGI script for creating new accounts."""
|
||||
|
||||
import json
|
||||
import random
|
||||
import secrets
|
||||
import string
|
||||
|
||||
@@ -15,7 +14,9 @@ ALPHANUMERIC_PUNCT = string.ascii_letters + string.digits + string.punctuation
|
||||
|
||||
|
||||
def create_newemail_dict(config: Config):
|
||||
user = "".join(random.choices(ALPHANUMERIC, k=config.username_max_length))
|
||||
user = "".join(
|
||||
secrets.choice(ALPHANUMERIC) for _ in range(config.username_max_length)
|
||||
)
|
||||
password = "".join(
|
||||
secrets.choice(ALPHANUMERIC_PUNCT)
|
||||
for _ in range(config.password_min_length + 3)
|
||||
|
||||
@@ -120,6 +120,60 @@ def test_handle_dovecot_protocol_iterate(gencreds, example_config):
|
||||
assert not lines[2]
|
||||
|
||||
|
||||
def test_invalid_localpart_characters(make_config):
|
||||
"""Test that is_allowed_to_create rejects localparts with invalid characters."""
|
||||
config = make_config("chat.example.org", {"username_min_length": "3"})
|
||||
password = "zequ0Aimuchoodaechik"
|
||||
domain = config.mail_domain
|
||||
|
||||
# valid localparts
|
||||
assert is_allowed_to_create(config, f"abc123@{domain}", password)
|
||||
assert is_allowed_to_create(config, f"a.b-c_d@{domain}", password)
|
||||
|
||||
# uppercase rejected
|
||||
assert not is_allowed_to_create(config, f"Abc123@{domain}", password)
|
||||
assert not is_allowed_to_create(config, f"ABCDEFG@{domain}", password)
|
||||
|
||||
# spaces and special chars rejected
|
||||
assert not is_allowed_to_create(config, f"a b cde@{domain}", password)
|
||||
assert not is_allowed_to_create(config, f"abc+def@{domain}", password)
|
||||
assert not is_allowed_to_create(config, f"abc!def@{domain}", password)
|
||||
assert not is_allowed_to_create(config, f"ab@cdef@{domain}", password)
|
||||
assert not is_allowed_to_create(config, f"abc/def@{domain}", password)
|
||||
assert not is_allowed_to_create(config, f"abc\\def@{domain}", password)
|
||||
|
||||
|
||||
def test_concurrent_creation_same_account(dictproxy):
|
||||
"""Test that concurrent creation of the same account doesn't corrupt password."""
|
||||
addr = "racetest1@chat.example.org"
|
||||
password = "zequ0Aimuchoodaechik"
|
||||
num_threads = 10
|
||||
results = queue.Queue()
|
||||
|
||||
def create():
|
||||
try:
|
||||
res = dictproxy.lookup_passdb(addr, password)
|
||||
results.put(("ok", res))
|
||||
except Exception:
|
||||
results.put(("err", traceback.format_exc()))
|
||||
|
||||
threads = [threading.Thread(target=create, daemon=True) for _ in range(num_threads)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join(timeout=10)
|
||||
|
||||
passwords_seen = set()
|
||||
for _ in range(num_threads):
|
||||
status, res = results.get()
|
||||
if status == "err":
|
||||
pytest.fail(f"concurrent creation failed\n{res}")
|
||||
passwords_seen.add(res["password"])
|
||||
|
||||
# all threads must see the same password hash
|
||||
assert len(passwords_seen) == 1
|
||||
|
||||
|
||||
def test_50_concurrent_lookups_different_accounts(gencreds, dictproxy):
|
||||
num_threads = 50
|
||||
req_per_thread = 5
|
||||
|
||||
@@ -112,6 +112,43 @@ def test_report(mbox1, example_config):
|
||||
report_main(args)
|
||||
|
||||
|
||||
def test_report_mdir_filters_by_path(mbox1, example_config):
|
||||
"""Test that Report with mdir='cur' only counts messages in cur/ subdirectory."""
|
||||
from chatmaild.fsreport import Report
|
||||
|
||||
now = datetime.utcnow().timestamp()
|
||||
|
||||
# Set password mtime to old enough so min_login_age check passes
|
||||
password = Path(mbox1.basedir).joinpath("password")
|
||||
old_time = now - 86400 * 10 # 10 days ago
|
||||
os.utime(password, (old_time, old_time))
|
||||
|
||||
# Reload mailbox with updated mtime
|
||||
from chatmaild.expire import MailboxStat
|
||||
|
||||
mbox = MailboxStat(mbox1.basedir)
|
||||
|
||||
# Report without mdir — should count all messages
|
||||
rep_all = Report(now=now, min_login_age=1, mdir=None)
|
||||
rep_all.process_mailbox_stat(mbox)
|
||||
total_all = rep_all.message_buckets[0]
|
||||
|
||||
# Report with mdir='cur' — should only count cur/ messages
|
||||
rep_cur = Report(now=now, min_login_age=1, mdir="cur")
|
||||
rep_cur.process_mailbox_stat(mbox)
|
||||
total_cur = rep_cur.message_buckets[0]
|
||||
|
||||
# Report with mdir='new' — should only count new/ messages
|
||||
rep_new = Report(now=now, min_login_age=1, mdir="new")
|
||||
rep_new.process_mailbox_stat(mbox)
|
||||
total_new = rep_new.message_buckets[0]
|
||||
|
||||
# cur has 500-byte msg, new has 600-byte msg (from fill_mbox)
|
||||
assert total_cur == 500
|
||||
assert total_new == 600
|
||||
assert total_all == 500 + 600
|
||||
|
||||
|
||||
def test_expiry_cli_basic(example_config, mbox1):
|
||||
args = (str(example_config._inipath),)
|
||||
expiry_main(args)
|
||||
|
||||
@@ -314,6 +314,51 @@ def test_persistent_queue_items(tmp_path, testaddr, token):
|
||||
assert not queue_item < item2 and not item2 < queue_item
|
||||
|
||||
|
||||
def test_turn_credentials_exception_returns_N(notifier, metadata, monkeypatch):
|
||||
"""Test that turn_credentials() failure returns N\\n instead of crashing."""
|
||||
import chatmaild.metadata
|
||||
|
||||
dictproxy = MetadataDictProxy(
|
||||
notifier=notifier,
|
||||
metadata=metadata,
|
||||
turn_hostname="turn.example.org",
|
||||
)
|
||||
|
||||
def mock_turn_credentials():
|
||||
raise ConnectionRefusedError("socket not available")
|
||||
|
||||
monkeypatch.setattr(chatmaild.metadata, "turn_credentials", mock_turn_credentials)
|
||||
|
||||
transactions = {}
|
||||
res = dictproxy.handle_dovecot_request(
|
||||
"Lshared/0123/vendor/vendor.dovecot/pvt/server/vendor/deltachat/turn"
|
||||
"\tuser@example.org",
|
||||
transactions,
|
||||
)
|
||||
assert res == "N\n"
|
||||
|
||||
|
||||
def test_turn_credentials_success(notifier, metadata, monkeypatch):
|
||||
"""Test that valid turn_credentials() returns TURN URI."""
|
||||
import chatmaild.metadata
|
||||
|
||||
dictproxy = MetadataDictProxy(
|
||||
notifier=notifier,
|
||||
metadata=metadata,
|
||||
turn_hostname="turn.example.org",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(chatmaild.metadata, "turn_credentials", lambda: "user:pass")
|
||||
|
||||
transactions = {}
|
||||
res = dictproxy.handle_dovecot_request(
|
||||
"Lshared/0123/vendor/vendor.dovecot/pvt/server/vendor/deltachat/turn"
|
||||
"\tuser@example.org",
|
||||
transactions,
|
||||
)
|
||||
assert res == "Oturn.example.org:3478:user:pass\n"
|
||||
|
||||
|
||||
def test_iroh_relay(dictproxy):
|
||||
rfile = io.BytesIO(
|
||||
b"\n".join(
|
||||
|
||||
73
chatmaild/src/chatmaild/tests/test_turnserver.py
Normal file
73
chatmaild/src/chatmaild/tests/test_turnserver.py
Normal file
@@ -0,0 +1,73 @@
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from chatmaild.turnserver import turn_credentials
|
||||
|
||||
SOCKET_PATH = "/run/chatmail-turn/turn.socket"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def turn_socket(tmp_path):
|
||||
"""Create a real Unix socket server at a temp path."""
|
||||
sock_path = str(tmp_path / "turn.socket")
|
||||
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
server.bind(sock_path)
|
||||
server.listen(1)
|
||||
yield sock_path, server
|
||||
server.close()
|
||||
|
||||
|
||||
def _call_turn_credentials(sock_path):
|
||||
"""Call turn_credentials but connect to sock_path instead of hardcoded path."""
|
||||
original_connect = socket.socket.connect
|
||||
|
||||
def patched_connect(self, address):
|
||||
if address == SOCKET_PATH:
|
||||
address = sock_path
|
||||
return original_connect(self, address)
|
||||
|
||||
with patch.object(socket.socket, "connect", patched_connect):
|
||||
return turn_credentials()
|
||||
|
||||
|
||||
def test_turn_credentials_timeout(turn_socket):
|
||||
"""Server accepts but never responds — must raise socket.timeout."""
|
||||
sock_path, server = turn_socket
|
||||
|
||||
def accept_and_hang():
|
||||
conn, _ = server.accept()
|
||||
time.sleep(30)
|
||||
conn.close()
|
||||
|
||||
t = threading.Thread(target=accept_and_hang, daemon=True)
|
||||
t.start()
|
||||
|
||||
with pytest.raises(socket.timeout):
|
||||
_call_turn_credentials(sock_path)
|
||||
|
||||
|
||||
def test_turn_credentials_connection_refused(tmp_path):
|
||||
"""Socket file doesn't exist — must raise ConnectionRefusedError or FileNotFoundError."""
|
||||
missing = str(tmp_path / "nonexistent.socket")
|
||||
with pytest.raises((ConnectionRefusedError, FileNotFoundError)):
|
||||
_call_turn_credentials(missing)
|
||||
|
||||
|
||||
def test_turn_credentials_success(turn_socket):
|
||||
"""Server responds with credentials — must return stripped string."""
|
||||
sock_path, server = turn_socket
|
||||
|
||||
def respond():
|
||||
conn, _ = server.accept()
|
||||
conn.sendall(b"testuser:testpass\n")
|
||||
conn.close()
|
||||
|
||||
t = threading.Thread(target=respond, daemon=True)
|
||||
t.start()
|
||||
|
||||
result = _call_turn_credentials(sock_path)
|
||||
assert result == "testuser:testpass"
|
||||
@@ -4,6 +4,7 @@ import socket
|
||||
|
||||
def turn_credentials() -> str:
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client_socket:
|
||||
client_socket.settimeout(5)
|
||||
client_socket.connect("/run/chatmail-turn/turn.socket")
|
||||
with client_socket.makefile("rb") as file:
|
||||
return file.readline().decode("utf-8").strip()
|
||||
|
||||
@@ -67,7 +67,7 @@ class AcmetoolDeployer(Deployer):
|
||||
)
|
||||
files.template(
|
||||
src=importlib.resources.files(__package__).joinpath("desired.yaml.j2"),
|
||||
dest=f"/var/lib/acme/desired/{self.domains[0]}", # 0 is mailhost TLD
|
||||
dest=f"/var/lib/acme/desired/{self.domains[0]}", # 0 is mailhost TLD
|
||||
user="root",
|
||||
group="root",
|
||||
mode="644",
|
||||
|
||||
@@ -5,11 +5,6 @@ import os
|
||||
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__):
|
||||
return importlib.resources.files(pkg).joinpath(arg)
|
||||
|
||||
|
||||
@@ -101,16 +101,11 @@ def run_cmd(args, out):
|
||||
env["CHATMAIL_WEBSITE_ONLY"] = "True" if args.website_only else ""
|
||||
env["CHATMAIL_DISABLE_MAIL"] = "True" if args.disable_mail 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()
|
||||
pyinf = "pyinfra --dry" if args.dry_run else "pyinfra"
|
||||
|
||||
cmd = f"{pyinf} --ssh-user root {ssh_host} {deploy_path} -y"
|
||||
if ssh_host in ["localhost", "@docker"]:
|
||||
if ssh_host == "@docker":
|
||||
env["CHATMAIL_DOCKER"] = "True"
|
||||
cmd = f"{pyinf} @local {deploy_path} -y"
|
||||
|
||||
if version.parse(pyinfra.__version__) < version.parse("3"):
|
||||
@@ -118,24 +113,15 @@ def run_cmd(args, out):
|
||||
return 1
|
||||
|
||||
try:
|
||||
retcode = out.check_call(cmd, env=env)
|
||||
out.check_call(cmd, env=env)
|
||||
if args.website_only:
|
||||
if retcode == 0:
|
||||
out.green("Website deployment completed.")
|
||||
else:
|
||||
out.red("Website deployment failed.")
|
||||
elif retcode == 0:
|
||||
out.green("Deploy completed, call `cmdeploy dns` next.")
|
||||
elif not args.dns_check_disabled and not remote_data["acme_account_url"]:
|
||||
out.red("Deploy completed but letsencrypt not configured")
|
||||
out.red("Run 'cmdeploy run' again")
|
||||
retcode = 0
|
||||
out.green("Website deployment completed.")
|
||||
else:
|
||||
out.red("Deploy failed")
|
||||
out.green("Deploy completed, call `cmdeploy dns` next.")
|
||||
return 0
|
||||
except subprocess.CalledProcessError:
|
||||
out.red("Deploy failed")
|
||||
retcode = 1
|
||||
return retcode
|
||||
return 1
|
||||
|
||||
|
||||
def dns_cmd_options(parser):
|
||||
|
||||
@@ -10,7 +10,6 @@ from pathlib import Path
|
||||
|
||||
from chatmaild.config import read_config
|
||||
from pyinfra import facts, host, logger
|
||||
from pyinfra.facts import hardware
|
||||
from pyinfra.api import FactBase
|
||||
from pyinfra.facts.files import Sha256File
|
||||
from pyinfra.facts.systemd import SystemdEnabled
|
||||
@@ -25,7 +24,6 @@ from .basedeploy import (
|
||||
activate_remote_units,
|
||||
configure_remote_units,
|
||||
get_resource,
|
||||
has_systemd,
|
||||
)
|
||||
from .dovecot.deployer import DovecotDeployer
|
||||
from .filtermail.deployer import FiltermailDeployer
|
||||
@@ -38,7 +36,7 @@ from .www import build_webpages, find_merge_conflict, get_paths
|
||||
|
||||
class Port(FactBase):
|
||||
"""
|
||||
Returns the process occupying a port.
|
||||
Returns the process occuping a port.
|
||||
"""
|
||||
|
||||
def command(self, port: int) -> str:
|
||||
@@ -66,8 +64,6 @@ def _build_chatmaild(dist_dir) -> None:
|
||||
|
||||
|
||||
def remove_legacy_artifacts():
|
||||
if not has_systemd():
|
||||
return
|
||||
# disable legacy doveauth-dictproxy.service
|
||||
if host.get_fact(SystemdEnabled).get("doveauth-dictproxy.service"):
|
||||
systemd.service(
|
||||
@@ -268,6 +264,9 @@ class WebsiteDeployer(Deployer):
|
||||
# if www_folder is a hugo page, build it
|
||||
if build_dir:
|
||||
www_path = build_webpages(src_dir, build_dir, self.config)
|
||||
if www_path is None:
|
||||
logger.warning("Web page build failed, skipping website deployment")
|
||||
return
|
||||
# if it is not a hugo page, upload it as is
|
||||
files.rsync(
|
||||
f"{www_path}/", "/var/www/html", flags=["-avz", "--chown=www-data"]
|
||||
@@ -302,7 +301,7 @@ class LegacyRemoveDeployer(Deployer):
|
||||
present=False,
|
||||
)
|
||||
# remove echobot if it is still running
|
||||
if has_systemd() and host.get_fact(SystemdEnabled).get("echobot.service"):
|
||||
if host.get_fact(SystemdEnabled).get("echobot.service"):
|
||||
systemd.service(
|
||||
name="Disable echobot.service",
|
||||
service="echobot.service",
|
||||
@@ -538,13 +537,12 @@ class GithashDeployer(Deployer):
|
||||
)
|
||||
|
||||
|
||||
def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool, docker: bool) -> None:
|
||||
def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -> None:
|
||||
"""Deploy a chat-mail instance.
|
||||
|
||||
:param config_path: path to chatmail.ini
|
||||
:param disable_mail: whether to disable postfix & dovecot
|
||||
:param website_only: if True, only deploy the website
|
||||
:param docker: whether it is running in a docker container
|
||||
"""
|
||||
config = read_config(config_path)
|
||||
check_config(config)
|
||||
@@ -562,43 +560,33 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool, d
|
||||
line="\nnameserver 9.9.9.9",
|
||||
)
|
||||
|
||||
# Check if mtail_address interface is available (if configured)
|
||||
if config.mtail_address and config.mtail_address not in ('127.0.0.1', '::1', 'localhost'):
|
||||
ipv4_addrs = host.get_fact(hardware.Ipv4Addrs)
|
||||
all_addresses = [addr for addrs in ipv4_addrs.values() for addr in addrs]
|
||||
if config.mtail_address not in all_addresses:
|
||||
Out().red(f"Deploy failed: mtail_address {config.mtail_address} is not available (VPN up?).\n")
|
||||
exit(1)
|
||||
|
||||
if not docker:
|
||||
port_services = [
|
||||
(["master", "smtpd"], 25),
|
||||
("unbound", 53),
|
||||
("acmetool", 80),
|
||||
(["imap-login", "dovecot"], 143),
|
||||
("nginx", 443),
|
||||
(["master", "smtpd"], 465),
|
||||
(["master", "smtpd"], 587),
|
||||
(["imap-login", "dovecot"], 993),
|
||||
("iroh-relay", 3340),
|
||||
("mtail", 3903),
|
||||
("stats", 3904),
|
||||
("nginx", 8443),
|
||||
(["master", "smtpd"], config.postfix_reinject_port),
|
||||
(["master", "smtpd"], config.postfix_reinject_port_incoming),
|
||||
("filtermail", config.filtermail_smtp_port),
|
||||
("filtermail", config.filtermail_smtp_port_incoming),
|
||||
]
|
||||
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)
|
||||
port_services = [
|
||||
(["master", "smtpd"], 25),
|
||||
("unbound", 53),
|
||||
("acmetool", 80),
|
||||
(["imap-login", "dovecot"], 143),
|
||||
("nginx", 443),
|
||||
(["master", "smtpd"], 465),
|
||||
(["master", "smtpd"], 587),
|
||||
(["imap-login", "dovecot"], 993),
|
||||
("iroh-relay", 3340),
|
||||
("mtail", 3903),
|
||||
("dovecot-stats", 3904),
|
||||
("nginx", 8443),
|
||||
(["master", "smtpd"], config.postfix_reinject_port),
|
||||
(["master", "smtpd"], config.postfix_reinject_port_incoming),
|
||||
("filtermail", config.filtermail_smtp_port),
|
||||
("filtermail", config.filtermail_smtp_port_incoming),
|
||||
]
|
||||
for service, port in port_services:
|
||||
print(f"Checking if port {port} is available for {service}...")
|
||||
running_service = host.get_fact(Port, port=port)
|
||||
if running_service:
|
||||
if running_service not in service:
|
||||
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}"]
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ from cmdeploy.basedeploy import (
|
||||
activate_remote_units,
|
||||
configure_remote_units,
|
||||
get_resource,
|
||||
has_systemd,
|
||||
)
|
||||
|
||||
|
||||
@@ -23,11 +22,10 @@ class DovecotDeployer(Deployer):
|
||||
|
||||
def install(self):
|
||||
arch = host.get_fact(Arch)
|
||||
if has_systemd() and "dovecot.service" in host.get_fact(SystemdEnabled):
|
||||
return # already installed and running
|
||||
_install_dovecot_package("core", arch)
|
||||
_install_dovecot_package("imapd", arch)
|
||||
_install_dovecot_package("lmtpd", arch)
|
||||
if not "dovecot.service" in host.get_fact(SystemdEnabled):
|
||||
_install_dovecot_package("core", arch)
|
||||
_install_dovecot_package("imapd", arch)
|
||||
_install_dovecot_package("lmtpd", arch)
|
||||
|
||||
def configure(self):
|
||||
configure_remote_units(self.config.mail_domain, self.units)
|
||||
@@ -39,7 +37,9 @@ class DovecotDeployer(Deployer):
|
||||
restart = False if self.disable_mail else self.need_restart
|
||||
|
||||
systemd.service(
|
||||
name="Disable dovecot for now" if self.disable_mail else "Start and enable Dovecot",
|
||||
name="Disable dovecot for now"
|
||||
if self.disable_mail
|
||||
else "Start and enable Dovecot",
|
||||
service="dovecot.service",
|
||||
running=False if self.disable_mail else True,
|
||||
enabled=False if self.disable_mail else True,
|
||||
@@ -118,19 +118,18 @@ def _configure_dovecot(config: Config, debug: bool = False) -> (bool, bool):
|
||||
|
||||
# as per https://doc.dovecot.org/2.3/configuration_manual/os/
|
||||
# it is recommended to set the following inotify limits
|
||||
if config.change_kernel_settings:
|
||||
for name in ("max_user_instances", "max_user_watches"):
|
||||
key = f"fs.inotify.{name}"
|
||||
if host.get_fact(Sysctl)[key] > 65535:
|
||||
# Skip updating limits if already sufficient
|
||||
# (enables running in incus containers where sysctl readonly)
|
||||
continue
|
||||
server.sysctl(
|
||||
name=f"Change {key}",
|
||||
key=key,
|
||||
value=65535,
|
||||
persist=True,
|
||||
)
|
||||
for name in ("max_user_instances", "max_user_watches"):
|
||||
key = f"fs.inotify.{name}"
|
||||
if host.get_fact(Sysctl)[key] > 65535:
|
||||
# Skip updating limits if already sufficient
|
||||
# (enables running in incus containers where sysctl readonly)
|
||||
continue
|
||||
server.sysctl(
|
||||
name=f"Change {key}",
|
||||
key=key,
|
||||
value=65535,
|
||||
persist=True,
|
||||
)
|
||||
|
||||
timezone_env = files.line(
|
||||
name="Set TZ environment variable",
|
||||
|
||||
@@ -14,10 +14,10 @@ class FiltermailDeployer(Deployer):
|
||||
|
||||
def install(self):
|
||||
arch = host.get_fact(facts.server.Arch)
|
||||
url = f"https://github.com/chatmail/filtermail/releases/download/v0.3.0/filtermail-{arch}"
|
||||
url = f"https://github.com/chatmail/filtermail/releases/download/v0.2.0/filtermail-{arch}-musl"
|
||||
sha256sum = {
|
||||
"x86_64": "f14a31323ae2dad3b59d3fdafcde507521da2f951a9478cd1f2fe2b4463df71d",
|
||||
"aarch64": "933770d75046c4fd7084ce8d43f905f8748333426ad839154f0fc654755ef09f",
|
||||
"x86_64": "1e5bbb646582cb16740c6dfbbca39edba492b78cc96ec9fa2528c612bb504edd",
|
||||
"aarch64": "3564fba8605f8f9adfeefff3f4580533205da043f47c5968d0d10db17e50f44e",
|
||||
}[arch]
|
||||
self.need_restart |= files.download(
|
||||
name="Download filtermail",
|
||||
|
||||
@@ -51,7 +51,7 @@ http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_certificate /var/lib/acme/live/{{ config.domain_name }}/fullchain;
|
||||
ssl_certificate_key /var/lib/acme/live/{{ config.domain_name }}/privkey;
|
||||
|
||||
@@ -61,20 +61,6 @@ class PostfixDeployer(Deployer):
|
||||
)
|
||||
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 = files.put(
|
||||
src=get_resource("postfix/login_map"),
|
||||
@@ -97,7 +83,9 @@ class PostfixDeployer(Deployer):
|
||||
server.shell(
|
||||
name="Validate postfix configuration",
|
||||
# Extract stderr and quit with error if non-zero
|
||||
commands=["""bash -c 'w=$(postconf 2>&1 >/dev/null); [[ -z "$w" ]] || { echo "$w"; false; }'"""],
|
||||
commands=[
|
||||
"""bash -c 'w=$(postconf 2>&1 >/dev/null); [[ -z "$w" ]] || { echo "$w"; false; }'"""
|
||||
],
|
||||
)
|
||||
self.need_restart = need_restart
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ smtp_tls_security_level=verify
|
||||
# <https://www.postfix.org/postconf.5.html#smtp_tls_servername>
|
||||
smtp_tls_servername = hostname
|
||||
smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache
|
||||
smtp_tls_policy_maps = regexp:/etc/postfix/smtp_tls_policy_map
|
||||
smtp_tls_policy_maps = inline:{nauta.cu=may}
|
||||
smtp_tls_protocols = >=TLSv1.2
|
||||
smtp_tls_mandatory_protocols = >=TLSv1.2
|
||||
|
||||
@@ -69,15 +69,6 @@ mynetworks = 127.0.0.0/8
|
||||
{% else %}
|
||||
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
|
||||
message_size_limit = {{config.max_message_size}}
|
||||
recipient_delimiter = +
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
/^\[[^]]+\]$/ encrypt
|
||||
/^nauta\.cu$/ may
|
||||
@@ -53,7 +53,7 @@ def get_dkim_entry(mail_domain, pre_command, dkim_selector):
|
||||
print=log_progress,
|
||||
)
|
||||
except CalledProcessError:
|
||||
return
|
||||
return None, None
|
||||
dkim_value_raw = f"v=DKIM1;k=rsa;p={dkim_pubkey};s=email;t=s"
|
||||
dkim_value = '" "'.join(re.findall(".{1,255}", dkim_value_raw))
|
||||
web_dkim_value = "".join(re.findall(".{1,255}", dkim_value_raw))
|
||||
|
||||
@@ -40,5 +40,5 @@ def dovecot_recalc_quota(user):
|
||||
#
|
||||
for line in output.split("\n"):
|
||||
parts = line.split()
|
||||
if parts[2] == "STORAGE":
|
||||
if len(parts) >= 6 and parts[2] == "STORAGE":
|
||||
return dict(value=int(parts[3]), limit=int(parts[4]), percent=int(parts[5]))
|
||||
|
||||
@@ -15,9 +15,8 @@ def main():
|
||||
)
|
||||
disable_mail = bool(os.environ.get("CHATMAIL_DISABLE_MAIL"))
|
||||
website_only = bool(os.environ.get("CHATMAIL_WEBSITE_ONLY"))
|
||||
docker = bool(os.environ.get("CHATMAIL_DOCKER"))
|
||||
|
||||
deploy_chatmail(config_path, disable_mail, website_only, docker)
|
||||
deploy_chatmail(config_path, disable_mail, website_only)
|
||||
|
||||
|
||||
if pyinfra.is_cli:
|
||||
|
||||
@@ -60,6 +60,29 @@ def mockdns(request, mockdns_base, mockdns_expected):
|
||||
return mockdns_base
|
||||
|
||||
|
||||
class TestGetDkimEntry:
|
||||
def test_dkim_entry_returns_tuple_on_success(self, mockdns):
|
||||
entry, web_entry = remote.rdns.get_dkim_entry(
|
||||
"some.domain", "", dkim_selector="opendkim"
|
||||
)
|
||||
# May return None,None if openssl not available, but should never crash
|
||||
if entry is not None:
|
||||
assert "opendkim._domainkey.some.domain" in entry
|
||||
assert "opendkim._domainkey.some.domain" in web_entry
|
||||
|
||||
def test_dkim_entry_returns_none_tuple_on_error(self, monkeypatch):
|
||||
"""CalledProcessError must return (None, None), not bare None."""
|
||||
from subprocess import CalledProcessError
|
||||
|
||||
def failing_shell(command, fail_ok=False, print=print):
|
||||
raise CalledProcessError(1, command)
|
||||
|
||||
monkeypatch.setattr(remote.rdns, "shell", failing_shell)
|
||||
result = remote.rdns.get_dkim_entry("some.domain", "", dkim_selector="opendkim")
|
||||
assert result == (None, None)
|
||||
assert result[0] is None and result[1] is None
|
||||
|
||||
|
||||
class TestPerformInitialChecks:
|
||||
def test_perform_initial_checks_ok1(self, mockdns, mockdns_expected):
|
||||
remote_data = remote.rdns.perform_initial_checks("some.domain")
|
||||
|
||||
68
cmdeploy/src/cmdeploy/tests/test_rshell.py
Normal file
68
cmdeploy/src/cmdeploy/tests/test_rshell.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from cmdeploy.remote.rshell import dovecot_recalc_quota
|
||||
|
||||
|
||||
def test_dovecot_recalc_quota_normal_output():
|
||||
"""Normal doveadm output returns parsed dict."""
|
||||
normal_output = (
|
||||
"Quota name Type Value Limit %\n"
|
||||
"User quota STORAGE 5 102400 0\n"
|
||||
"User quota MESSAGE 2 - 0\n"
|
||||
)
|
||||
|
||||
with patch("cmdeploy.remote.rshell.shell", return_value=normal_output):
|
||||
result = dovecot_recalc_quota("user@example.org")
|
||||
|
||||
# shell is called twice (recalc + get), patch returns same for both
|
||||
assert result == {"value": 5, "limit": 102400, "percent": 0}
|
||||
|
||||
|
||||
def test_dovecot_recalc_quota_empty_output():
|
||||
"""Empty doveadm output (trailing newline) must not IndexError."""
|
||||
call_count = [0]
|
||||
|
||||
def mock_shell(cmd):
|
||||
call_count[0] += 1
|
||||
if "recalc" in cmd:
|
||||
return ""
|
||||
# quota get returns only empty lines
|
||||
return "\n\n"
|
||||
|
||||
with patch("cmdeploy.remote.rshell.shell", side_effect=mock_shell):
|
||||
result = dovecot_recalc_quota("user@example.org")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_dovecot_recalc_quota_malformed_output():
|
||||
"""Malformed output with too few columns must not crash."""
|
||||
call_count = [0]
|
||||
|
||||
def mock_shell(cmd):
|
||||
call_count[0] += 1
|
||||
if "recalc" in cmd:
|
||||
return ""
|
||||
# partial line, fewer than 6 parts
|
||||
return "Quota name\nUser quota STORAGE\n"
|
||||
|
||||
with patch("cmdeploy.remote.rshell.shell", side_effect=mock_shell):
|
||||
result = dovecot_recalc_quota("user@example.org")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_dovecot_recalc_quota_header_only():
|
||||
"""Only header line, no data rows."""
|
||||
call_count = [0]
|
||||
|
||||
def mock_shell(cmd):
|
||||
call_count[0] += 1
|
||||
if "recalc" in cmd:
|
||||
return ""
|
||||
return "Quota name Type Value Limit %\n"
|
||||
|
||||
with patch("cmdeploy.remote.rshell.shell", side_effect=mock_shell):
|
||||
result = dovecot_recalc_quota("user@example.org")
|
||||
|
||||
assert result is None
|
||||
@@ -80,13 +80,6 @@ steps. Please substitute it with your own domain.
|
||||
configure at your DNS provider (it can take some time until they are
|
||||
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
|
||||
----------------------
|
||||
|
||||
|
||||
@@ -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
|
||||
to operate as a chatmail relay.
|
||||
|
||||
- `Madmail <https://github.com/themadorg/madmail>`_: an
|
||||
experimental fork of `Maddy Mail Server <https://maddy.email/>`_, modified
|
||||
- `Madmail <https://github.com/omidz4t/madmail>`_: an
|
||||
experimental fork of Maddy Mail Server <https://maddy.email/>`_ optimized
|
||||
for chatmail deployments. It provides a single binary solution
|
||||
for running a chatmail relay.
|
||||
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
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
|
||||
@@ -1,100 +0,0 @@
|
||||
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" ]
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
#!/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()
|
||||
@@ -1,11 +0,0 @@
|
||||
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.
|
||||
@@ -1,11 +0,0 @@
|
||||
#!/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 $@
|
||||
@@ -1,14 +0,0 @@
|
||||
[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
|
||||
@@ -1,84 +0,0 @@
|
||||
#!/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 &
|
||||
@@ -1,79 +0,0 @@
|
||||
#!/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"
|
||||
@@ -1,185 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
@@ -1,174 +0,0 @@
|
||||
# Известные проблемы и ограничения
|
||||
- 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