Compare commits

..

21 Commits

Author SHA1 Message Date
missytake
4f8b958a57 config: comment out values in chatmail.ini.f, so defaults take precedence 2026-02-24 14:44:46 +01:00
missytake
ca2103a4d3 config: default delete_inactive_users_after is 90, actually 2026-02-24 14:43:58 +01:00
missytake
13dd64798a config: load default values from Config(), not chatmail.ini.f 2026-02-19 21:41:17 +01:00
missytake
38cc1c7cd6 fix(cmdeploy): make tests work with --ssh-host localhost (#856)
* tests: fix test_remote[imap]
* cmdeploy: call LocalExec directly, not .logged()
* tests: fix TestSSHExecutor.test_logged
* tests: fix test_status_cmd with --ssh-host @local
* tests: fix test_logged with --ssh-host localhost
* tests: fix TestSSHExecutor::test_exception with --ssh-host localhost
* ci: deploy with --ssh-host localhost on staging-ipv4
* metadata: lower RestartSec
2026-02-19 21:34:39 +01:00
link2xt
7a6ed8340e test: mark f-string with f prefix in test_expunged
This one was not marked accidentally.
2026-02-19 19:41:14 +00:00
missytake
2ce9e5fe78 dovecot: install also if dovecot.service=False in SystemdEnabled Fact 2026-02-19 16:00:25 +01:00
holger krekel
cf96be2cbb feat: support self-signed chatmail relays (#855)
feat: support self-signed TLS via underscore domain convention
Domains starting with "_" (e.g. _chat.example.org) automatically use
self-signed TLS certificates instead of ACME/Let's Encrypt. The TLS
mode is derived from the domain name — no separate config option needed.

Internally, when config.tls_cert_mode is "self" (underscore domain):
- Generate self-signed certificates via openssl
- Set Postfix smtp_tls_security_level to "encrypt" (opportunistic TLS)
- Add smtp_tls_policy_map entry for underscore domains
- Skip ACME, MTA-STS and www CNAME checks in `cmdeploy dns`
- Serve /new via GET (not redirect to dcaccount:) with rate-limiting
  (nginx limit_req, 2r/s burst=5)
- Return dclogin: URLs with ic=3 (AcceptInvalidCertificates) from /new
- Render QR codes client-side via JavaScript and qrcode-svg
- Use config.tls_cert_path/tls_key_path in Postfix, Dovecot and nginx
  templates instead of hardcoded ACME paths
2026-02-19 10:27:41 +01:00
Mark Felder
36eb63faa1 feat: Strip Received headers before delivery 2026-02-17 21:16:11 +01:00
Jagoda Estera Ślązak
91df11015e chore(deps): upgrade to filtermail v0.3 (#850)
## 0.3.0 - 2026-02-14

### Features

- Support legacy, pre-OpenPGP packet format

### Miscellaneous Tasks

- *(dist)* Switch to musl targets

### Refactor

- Remove unnecessary Arc
- Use a custom, minimal SMTP client instead of lettre

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-02-14 18:02:05 +01:00
link2xt
d4f8a29243 docs: fix link to Maddy and update madmail URL 2026-02-13 09:49:29 +00:00
missytake
0144fc3ea8 postfix: only look for square brackets, they are only allowed for address literals 2026-02-12 10:45:15 +01:00
missytake
e7ce6679b9 postfix: IPv6 literals have a prefix 2026-02-12 10:45:15 +01:00
missytake
d1adf52f89 postfix: also accept self-signed for IPv6-only 2026-02-12 10:45:15 +01:00
missytake
56d0e2ca27 postfix: be more exact with nauta.cu 2026-02-12 10:45:15 +01:00
missytake
2613558db6 postfix uses POSIX EREs, not PCRE, so some stuff doesn't work 2026-02-12 10:45:15 +01:00
missytake
6843fcb1a0 postfix: fix tls policy regexp map 2026-02-12 10:45:15 +01:00
missytake
ff54ad88d8 postfix: use regexp to match IPv4 addresses 2026-02-12 10:45:15 +01:00
missytake
cce2b27ae7 postfix: accept self-signed certificates for IP-only relays 2026-02-12 10:45:15 +01:00
j4n
87022e3681 fix(cmdeploy): check if dns_check_disabled before trying to warn about LE
If --skip-dns-check is used and retcode != 0, remote_data is undefined.
2026-02-11 12:13:24 +01:00
j4n
06560dd071 feat(postfix): bind to mail_domain's A/AAAA addresses for outbound mail
Carry forward A/AAAA address from the DNS check to the postfix deploy
stage and set accordingly in main.cf.
2026-02-11 12:13:24 +01:00
j4n
1b0337a5f7 fix(cmdeploy): port check: check addresses, fix single services
Ensure that the interface for mtail_address is available and fix a bug
in port checking where single services were always passing regardless of
the specified service name.
2026-02-11 09:36:04 +01:00
42 changed files with 520 additions and 182 deletions

View File

@@ -15,7 +15,7 @@ jobs:
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
- name: download filtermail - name: download 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 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

View File

@@ -71,26 +71,35 @@ jobs:
- name: run deploy-chatmail offline tests - name: run deploy-chatmail offline tests
run: pytest --pyargs cmdeploy run: pytest --pyargs cmdeploy
- run: | - name: setup dependencies
cmdeploy init staging-ipv4.testrun.org run: |
sed -i 's#disable_ipv6 = False#disable_ipv6 = True#' chatmail.ini ssh root@staging-ipv4.testrun.org apt update
sed -i 's/#\s*mtail_address/mtail_address/' chatmail.ini ssh root@staging-ipv4.testrun.org apt install -y git python3.11-venv python3-dev gcc
ssh root@staging-ipv4.testrun.org git clone https://github.com/chatmail/relay
ssh root@staging-ipv4.testrun.org "cd relay && git checkout " ${{ github.head_ref }}
ssh root@staging-ipv4.testrun.org "cd relay && scripts/initenv.sh"
- run: cmdeploy run --verbose --skip-dns-check - name: initialize config
run: |
ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy init staging-ipv4.testrun.org"
ssh root@staging-ipv4.testrun.org "sed -i 's#disable_ipv6 = False#disable_ipv6 = True#' relay/chatmail.ini"
ssh root@staging-ipv4.testrun.org "sed -i 's/#\s*mtail_address/mtail_address/' relay/chatmail.ini"
- run: ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy run --verbose --skip-dns-check --ssh-host localhost"
- name: set DNS entries - name: set DNS entries
run: | run: |
ssh -o StrictHostKeyChecking=accept-new -v root@staging-ipv4.testrun.org chown opendkim:opendkim -R /etc/dkimkeys ssh root@staging-ipv4.testrun.org chown opendkim:opendkim -R /etc/dkimkeys
cmdeploy dns --zonefile staging-generated.zone ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy dns --zonefile staging-generated.zone --ssh-host localhost"
cat staging-generated.zone >> .github/workflows/staging-ipv4.testrun.org-default.zone ssh root@staging-ipv4.testrun.org cat relay/staging-generated.zone >> .github/workflows/staging-ipv4.testrun.org-default.zone
cat .github/workflows/staging-ipv4.testrun.org-default.zone cat .github/workflows/staging-ipv4.testrun.org-default.zone
scp .github/workflows/staging-ipv4.testrun.org-default.zone root@ns.testrun.org:/etc/nsd/staging-ipv4.testrun.org.zone scp .github/workflows/staging-ipv4.testrun.org-default.zone root@ns.testrun.org:/etc/nsd/staging-ipv4.testrun.org.zone
ssh root@ns.testrun.org nsd-checkzone staging-ipv4.testrun.org /etc/nsd/staging-ipv4.testrun.org.zone ssh root@ns.testrun.org nsd-checkzone staging-ipv4.testrun.org /etc/nsd/staging-ipv4.testrun.org.zone
ssh root@ns.testrun.org systemctl reload nsd ssh root@ns.testrun.org systemctl reload nsd
- name: cmdeploy test - name: cmdeploy test
run: CHATMAIL_DOMAIN2=ci-chatmail.testrun.org cmdeploy test --slow run: ssh root@staging-ipv4.testrun.org "cd relay && CHATMAIL_DOMAIN2=ci-chatmail.testrun.org scripts/cmdeploy test --slow --ssh-host localhost"
- name: cmdeploy dns - name: cmdeploy dns
run: cmdeploy dns -v run: ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy dns -v --ssh-host localhost"

View File

@@ -1,3 +1,4 @@
import os
from pathlib import Path from pathlib import Path
import iniconfig import iniconfig
@@ -8,12 +9,7 @@ from chatmaild.user import User
def read_config(inipath): def read_config(inipath):
assert Path(inipath).exists(), inipath assert Path(inipath).exists(), inipath
cfg = iniconfig.IniConfig(inipath) cfg = iniconfig.IniConfig(inipath)
params = cfg.sections["params"] return Config(inipath, params=cfg.sections["params"])
default_config_content = get_default_config_content(params["mail_domain"])
df_params = iniconfig.IniConfig("ini", data=default_config_content)["params"]
new_params = dict(df_params.items())
new_params.update(params)
return Config(inipath, params=new_params)
class Config: class Config:
@@ -22,16 +18,18 @@ class Config:
self.mail_domain = params["mail_domain"] self.mail_domain = params["mail_domain"]
self.max_user_send_per_minute = int(params.get("max_user_send_per_minute", 60)) 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_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.get("max_mailbox_size", "500M")
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.get("delete_mails_after", "20")
self.delete_large_after = params["delete_large_after"] self.delete_large_after = params.get("delete_large_after", "7")
self.delete_inactive_users_after = int(params["delete_inactive_users_after"]) self.delete_inactive_users_after = int(
self.username_min_length = int(params["username_min_length"]) params.get("delete_inactive_users_after", 90)
self.username_max_length = int(params["username_max_length"]) )
self.password_min_length = int(params["password_min_length"]) self.username_min_length = int(params.get("username_min_length", 9))
self.passthrough_senders = params["passthrough_senders"].split() self.username_max_length = int(params.get("username_max_length", 9))
self.passthrough_recipients = params["passthrough_recipients"].split() self.password_min_length = int(params.get("password_min_length", 9))
self.passthrough_senders = params.get("passthrough_senders", "").split()
self.passthrough_recipients = params.get("passthrough_recipients", "").split()
self.www_folder = params.get("www_folder", "") self.www_folder = params.get("www_folder", "")
self.filtermail_smtp_port = int(params.get("filtermail_smtp_port", "10080")) self.filtermail_smtp_port = int(params.get("filtermail_smtp_port", "10080"))
self.filtermail_smtp_port_incoming = int( self.filtermail_smtp_port_incoming = int(
@@ -43,6 +41,8 @@ class Config:
) )
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.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"
@@ -56,7 +56,18 @@ class Config:
self.privacy_mail = params.get("privacy_mail") self.privacy_mail = params.get("privacy_mail")
self.privacy_pdo = params.get("privacy_pdo") self.privacy_pdo = params.get("privacy_pdo")
self.privacy_supervisor = params.get("privacy_supervisor") self.privacy_supervisor = params.get("privacy_supervisor")
self.tmpfs_index = params.get("tmpfs_index", "false").lower() == "true"
# TLS certificate management: derived from the domain name.
# Domains starting with "_" use self-signed certificates
# All other domains use ACME.
if self.mail_domain.startswith("_"):
self.tls_cert_mode = "self"
self.tls_cert_path = "/etc/ssl/certs/mailserver.pem"
self.tls_key_path = "/etc/ssl/private/mailserver.key"
else:
self.tls_cert_mode = "acme"
self.tls_cert_path = f"/var/lib/acme/live/{self.mail_domain}/fullchain"
self.tls_key_path = f"/var/lib/acme/live/{self.mail_domain}/privkey"
# deprecated option # deprecated option
mbdir = params.get("mailboxes_dir", f"/home/vmail/mail/{self.mail_domain}") mbdir = params.get("mailboxes_dir", f"/home/vmail/mail/{self.mail_domain}")
@@ -112,10 +123,10 @@ def get_default_config_content(mail_domain, **overrides):
if mail_domain.endswith(".testrun.org"): if mail_domain.endswith(".testrun.org"):
override_inipath = inidir.joinpath("override-testrun.ini") override_inipath = inidir.joinpath("override-testrun.ini")
params = iniconfig.IniConfig(override_inipath)["params"] privacy = iniconfig.IniConfig(override_inipath)["privacy"]
lines = [] lines = []
for line in content.split("\n"): for line in content.split("\n"):
for key, value in params.items(): for key, value in privacy.items():
value_lines = value.format(mail_domain=mail_domain).strip().split("\n") value_lines = value.format(mail_domain=mail_domain).strip().split("\n")
if not line.startswith(f"{key} =") or not value_lines: if not line.startswith(f"{key} =") or not value_lines:
continue continue

View File

@@ -17,14 +17,14 @@ from chatmaild.config import read_config
FileEntry = namedtuple("FileEntry", ("path", "mtime", "size")) FileEntry = namedtuple("FileEntry", ("path", "mtime", "size"))
def iter_mailboxes(basedir, maxnum, tmpfs_index): def iter_mailboxes(basedir, maxnum):
if not os.path.exists(basedir): if not os.path.exists(basedir):
print_info(f"no mailboxes found at: {basedir}") print_info(f"no mailboxes found at: {basedir}")
return return
for name in os_listdir_if_exists(basedir)[:maxnum]: for name in os_listdir_if_exists(basedir)[:maxnum]:
if "@" in name: if "@" in name:
yield MailboxStat(basedir + "/" + name, name, tmpfs_index) yield MailboxStat(basedir + "/" + name)
def get_file_entry(path): def get_file_entry(path):
@@ -49,14 +49,11 @@ def os_listdir_if_exists(path):
class MailboxStat: class MailboxStat:
last_login = None last_login = None
def __init__(self, basedir, name, tmpfs_index): def __init__(self, basedir):
self.basedir = str(basedir) self.basedir = str(basedir)
self.name = name
self.messages = [] self.messages = []
self.extrafiles = [] self.extrafiles = []
self.scandir(self.basedir) self.scandir(self.basedir)
if tmpfs_index:
self.scandir("/dev/shm/" + name)
def scandir(self, folderdir): def scandir(self, folderdir):
for name in os_listdir_if_exists(folderdir): for name in os_listdir_if_exists(folderdir):
@@ -93,13 +90,11 @@ class Expiry:
self.all_files = 0 self.all_files = 0
self.start = time.time() self.start = time.time()
def remove_mailbox(self, mboxdir, name): def remove_mailbox(self, mboxdir):
if self.verbose: if self.verbose:
print_info(f"removing {mboxdir}") print_info(f"removing {mboxdir}")
if not self.dry: if not self.dry:
shutil.rmtree(mboxdir) shutil.rmtree(mboxdir)
if self.config.tmpfs_index:
shutil.rmtree("/dev/shm/" + name)
self.del_mboxes += 1 self.del_mboxes += 1
def remove_file(self, path, mtime=None): def remove_file(self, path, mtime=None):
@@ -126,7 +121,7 @@ class Expiry:
self.all_mboxes += 1 self.all_mboxes += 1
changed = False changed = False
if mbox.last_login and mbox.last_login < cutoff_without_login: if mbox.last_login and mbox.last_login < cutoff_without_login:
self.remove_mailbox(mbox.basedir, mbox.name) self.remove_mailbox(mbox.basedir)
return return
mboxname = os.path.basename(mbox.basedir) mboxname = os.path.basename(mbox.basedir)
@@ -150,9 +145,6 @@ class Expiry:
changed = True changed = True
if changed: if changed:
self.remove_file(f"{mbox.basedir}/maildirsize") self.remove_file(f"{mbox.basedir}/maildirsize")
for file in mbox.extrafiles:
if "dovecot.index" in file.path.split("/")[-1] and file.size > 500 * 1024:
self.remove_file(file.path)
def get_summary(self): def get_summary(self):
return ( return (
@@ -205,9 +197,7 @@ def main(args=None):
maxnum = int(args.maxnum) if args.maxnum else None maxnum = int(args.maxnum) if args.maxnum else None
exp = Expiry(config, dry=not args.remove, now=now, verbose=args.verbose) exp = Expiry(config, dry=not args.remove, now=now, verbose=args.verbose)
for mailbox in iter_mailboxes( for mailbox in iter_mailboxes(str(config.mailboxes_dir), maxnum=maxnum):
str(config.mailboxes_dir), maxnum, config.tmpfs_index
):
exp.process_mailbox_stat(mailbox) exp.process_mailbox_stat(mailbox)
print(exp.get_summary()) print(exp.get_summary())

View File

@@ -159,7 +159,7 @@ def main(args=None):
maxnum = int(args.maxnum) if args.maxnum else None maxnum = int(args.maxnum) if args.maxnum else None
rep = Report(now=now, min_login_age=int(args.min_login_age), mdir=args.mdir) rep = Report(now=now, min_login_age=int(args.min_login_age), mdir=args.mdir)
for mbox in iter_mailboxes(str(config.mailboxes_dir), maxnum, config.tmpfs_index): for mbox in iter_mailboxes(str(config.mailboxes_dir), maxnum=maxnum):
rep.process_mailbox_stat(mbox) rep.process_mailbox_stat(mbox)
rep.dump_summary() rep.dump_summary()

View File

@@ -12,44 +12,41 @@ mail_domain = {mail_domain}
# #
# email sending rate per user and 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) # per-user max burst size for sending rate limiting (GCRA bucket capacity)
max_user_send_burst_size = 10 #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
# maximum message size for an e-mail in bytes # maximum message size for an e-mail in bytes
max_message_size = 31457280 #max_message_size = 31457280
# days after which mails are unconditionally deleted # days after which mails are unconditionally deleted
delete_mails_after = 20 #delete_mails_after = 20
# days after which large messages (>200k) are unconditionally deleted # days after which large messages (>200k) are unconditionally deleted
delete_large_after = 7 #delete_large_after = 7
# days after which users without a successful login are deleted (database and mails) # days after which users without a successful login are deleted (database and mails)
delete_inactive_users_after = 90 #delete_inactive_users_after = 90
# minimum length a username must have # minimum length a username must have
username_min_length = 9 #username_min_length = 9
# maximum length a username can have # maximum length a username can have
username_max_length = 9 #username_max_length = 9
# minimum length a password must have # minimum length a password must have
password_min_length = 9 #password_min_length = 9
# list of chatmail addresses which can send outbound un-encrypted mail # list of chatmail addresses which can send outbound un-encrypted mail
passthrough_senders = #passthrough_senders =
# list of e-mail recipients for which to accept outbound un-encrypted mails # list of e-mail recipients for which to accept outbound un-encrypted mails
# (space-separated, item may start with "@" to whitelist whole recipient domains) # (space-separated, item may start with "@" to whitelist whole recipient domains)
passthrough_recipients = #passthrough_recipients =
# store index files in tmpfs (good for disk size and I/O, bad for ram)
tmpfs_index = false
# path to www directory - documented here: https://chatmail.at/doc/relay/getting_started.html#custom-web-pages # path to www directory - documented here: https://chatmail.at/doc/relay/getting_started.html#custom-web-pages
#www_folder = www #www_folder = www
@@ -59,18 +56,18 @@ tmpfs_index = false
# #
# SMTP outgoing filtermail and reinjection # SMTP outgoing filtermail and reinjection
filtermail_smtp_port = 10080 #filtermail_smtp_port = 10080
postfix_reinject_port = 10025 #postfix_reinject_port = 10025
# SMTP incoming filtermail and reinjection # SMTP incoming filtermail and reinjection
filtermail_smtp_port_incoming = 10081 #filtermail_smtp_port_incoming = 10081
postfix_reinject_port_incoming = 10026 #postfix_reinject_port_incoming = 10026
# if set to "True" IPv6 is disabled # if set to "True" IPv6 is disabled
disable_ipv6 = False #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 =
# 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.
@@ -103,13 +100,13 @@ acme_email =
# in per-maildir ".in/.out" files. # in per-maildir ".in/.out" files.
# Note that you need to manually cleanup these files # Note that you need to manually cleanup these files
# so use this option with caution on production servers. # so use this option with caution on production servers.
imap_rawlog = false #imap_rawlog = false
# set to true if you want to enable the IMAP COMPRESS Extension, # set to true if you want to enable the IMAP COMPRESS Extension,
# which allows IMAP connections to be efficiently compressed. # which allows IMAP connections to be efficiently compressed.
# WARNING: Enabling this makes it impossible to hibernate IMAP # WARNING: Enabling this makes it impossible to hibernate IMAP
# processes which will result in much higher memory/RAM usage. # processes which will result in much higher memory/RAM usage.
imap_compress = false #imap_compress = false
# #

View File

@@ -1,6 +1,5 @@
[params]
tmpfs_index = true [privacy]
passthrough_recipients = privacy@testrun.org echo@{mail_domain} passthrough_recipients = privacy@testrun.org echo@{mail_domain}

View File

@@ -6,6 +6,7 @@ import json
import random import random
import secrets import secrets
import string import string
from urllib.parse import quote
from chatmaild.config import Config, read_config from chatmaild.config import Config, read_config
@@ -23,13 +24,26 @@ def create_newemail_dict(config: Config):
return dict(email=f"{user}@{config.mail_domain}", password=f"{password}") return dict(email=f"{user}@{config.mail_domain}", password=f"{password}")
def create_dclogin_url(email, password):
"""Build a dclogin: URL with credentials and self-signed cert acceptance.
Uses ic=3 (AcceptInvalidCertificates) so chatmail clients
can connect to servers with self-signed TLS certificates.
"""
return f"dclogin:{quote(email, safe='@')}?p={quote(password, safe='')}&v=1&ic=3"
def print_new_account(): def print_new_account():
config = read_config(CONFIG_PATH) config = read_config(CONFIG_PATH)
creds = create_newemail_dict(config) creds = create_newemail_dict(config)
result = dict(email=creds["email"], password=creds["password"])
if config.tls_cert_mode == "self":
result["dclogin_url"] = create_dclogin_url(creds["email"], creds["password"])
print("Content-Type: application/json") print("Content-Type: application/json")
print("") print("")
print(json.dumps(creds)) print(json.dumps(result))
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -73,3 +73,17 @@ def test_config_userstate_paths(make_config, tmp_path):
def test_config_max_message_size(make_config, tmp_path): def test_config_max_message_size(make_config, tmp_path):
config = make_config("something.testrun.org", dict(max_message_size="10000")) config = make_config("something.testrun.org", dict(max_message_size="10000"))
assert config.max_message_size == 10000 assert config.max_message_size == 10000
def test_config_tls_default_acme(make_config):
config = make_config("chat.example.org")
assert config.tls_cert_mode == "acme"
assert config.tls_cert_path == "/var/lib/acme/live/chat.example.org/fullchain"
assert config.tls_key_path == "/var/lib/acme/live/chat.example.org/privkey"
def test_config_tls_self(make_config):
config = make_config("_test.example.org")
assert config.tls_cert_mode == "self"
assert config.tls_cert_path == "/etc/ssl/certs/mailserver.pem"
assert config.tls_key_path == "/etc/ssl/private/mailserver.key"

View File

@@ -43,22 +43,20 @@ def create_new_messages(basedir, relpaths, size=1000, days=0):
@pytest.fixture @pytest.fixture
def mbox1(example_config): def mbox1(example_config):
addr = "mailbox1@example.org" mboxdir = example_config.mailboxes_dir.joinpath("mailbox1@example.org")
mboxdir = example_config.mailboxes_dir.joinpath(addr)
mboxdir.mkdir() mboxdir.mkdir()
fill_mbox(mboxdir) fill_mbox(mboxdir)
return MailboxStat(mboxdir, addr, False) return MailboxStat(mboxdir)
def test_deltachat_folder(example_config): def test_deltachat_folder(example_config):
"""Test old setups that might have a .DeltaChat folder where messages also need to get removed.""" """Test old setups that might have a .DeltaChat folder where messages also need to get removed."""
addr = "mailbox1@example.org" mboxdir = example_config.mailboxes_dir.joinpath("mailbox1@example.org")
mboxdir = example_config.mailboxes_dir.joinpath(addr)
mboxdir.mkdir() mboxdir.mkdir()
mbox2dir = mboxdir.joinpath(".DeltaChat") mbox2dir = mboxdir.joinpath(".DeltaChat")
mbox2dir.mkdir() mbox2dir.mkdir()
fill_mbox(mbox2dir) fill_mbox(mbox2dir)
mb = MailboxStat(mboxdir, addr, False) mb = MailboxStat(mboxdir)
assert len(mb.messages) == 2 assert len(mb.messages) == 2
@@ -71,11 +69,7 @@ def test_filentry_ordering(tmp_path):
def test_no_mailbxoes(tmp_path, capsys): def test_no_mailbxoes(tmp_path, capsys):
assert [] == list( assert [] == list(iter_mailboxes(str(tmp_path.joinpath("notexists")), maxnum=10))
iter_mailboxes(
str(tmp_path.joinpath("notexists")), maxnum=10, tmpfs_index=False
)
)
out, err = capsys.readouterr() out, err = capsys.readouterr()
assert "no mailboxes" in err assert "no mailboxes" in err
@@ -92,13 +86,13 @@ def test_stats_mailbox(mbox1):
create_new_messages(mbox1.basedir, ["large-extra"], size=1000) create_new_messages(mbox1.basedir, ["large-extra"], size=1000)
create_new_messages(mbox1.basedir, ["index-something"], size=3) create_new_messages(mbox1.basedir, ["index-something"], size=3)
mbox2 = MailboxStat(mbox1.basedir, mbox1.name, False) mbox2 = MailboxStat(mbox1.basedir)
assert len(mbox2.extrafiles) == 5 assert len(mbox2.extrafiles) == 5
assert mbox2.extrafiles[0].size == 1000 assert mbox2.extrafiles[0].size == 1000
# cope well with mailbox dirs that have no password (for whatever reason) # cope well with mailbox dirs that have no password (for whatever reason)
Path(mbox1.basedir).joinpath("password").unlink() Path(mbox1.basedir).joinpath("password").unlink()
mbox3 = MailboxStat(mbox1.basedir, mbox1.name, False) mbox3 = MailboxStat(mbox1.basedir)
assert mbox3.last_login is None assert mbox3.last_login is None

View File

@@ -1,9 +1,15 @@
import shutil
import smtplib import smtplib
import subprocess import subprocess
import sys import sys
import pytest import pytest
pytestmark = pytest.mark.skipif(
shutil.which("filtermail") is None,
reason="filtermail binary not found",
)
@pytest.fixture @pytest.fixture
def smtpserver(): def smtpserver():

View File

@@ -1,7 +1,11 @@
import json import json
import chatmaild import chatmaild
from chatmaild.newemail import create_newemail_dict, print_new_account from chatmaild.newemail import (
create_dclogin_url,
create_newemail_dict,
print_new_account,
)
def test_create_newemail_dict(example_config): def test_create_newemail_dict(example_config):
@@ -15,6 +19,18 @@ def test_create_newemail_dict(example_config):
assert ac1["password"] != ac2["password"] assert ac1["password"] != ac2["password"]
def test_create_dclogin_url():
url = create_dclogin_url("user@example.org", "p@ss w+rd")
assert url.startswith("dclogin:")
assert "v=1" in url
assert "ic=3" in url
assert "user@example.org" in url
# password special chars must be encoded
assert "p%40ss" in url
assert "w%2Brd" in url
def test_print_new_account(capsys, monkeypatch, maildomain, tmpdir, example_config): def test_print_new_account(capsys, monkeypatch, maildomain, tmpdir, example_config):
monkeypatch.setattr(chatmaild.newemail, "CONFIG_PATH", str(example_config._inipath)) monkeypatch.setattr(chatmaild.newemail, "CONFIG_PATH", str(example_config._inipath))
print_new_account() print_new_account()
@@ -25,3 +41,20 @@ def test_print_new_account(capsys, monkeypatch, maildomain, tmpdir, example_conf
dic = json.loads(lines[2]) dic = json.loads(lines[2])
assert dic["email"].endswith(f"@{example_config.mail_domain}") assert dic["email"].endswith(f"@{example_config.mail_domain}")
assert len(dic["password"]) >= 10 assert len(dic["password"]) >= 10
# default tls_cert=acme should not include dclogin_url
assert "dclogin_url" not in dic
def test_print_new_account_self_signed(capsys, monkeypatch, make_config):
config = make_config("_test.example.org")
monkeypatch.setattr(chatmaild.newemail, "CONFIG_PATH", str(config._inipath))
print_new_account()
out, err = capsys.readouterr()
lines = out.split("\n")
dic = json.loads(lines[2])
assert "dclogin_url" in dic
url = dic["dclogin_url"]
assert url.startswith("dclogin:")
assert "ic=3" in url
assert dic["email"].split("@")[0] in url

View File

@@ -8,8 +8,10 @@
{{ mail_domain }}. AAAA {{ AAAA }} {{ mail_domain }}. AAAA {{ AAAA }}
{% endif %} {% endif %}
{{ mail_domain }}. MX 10 {{ mail_domain }}. {{ mail_domain }}. MX 10 {{ mail_domain }}.
{% if strict_tls %}
_mta-sts.{{ mail_domain }}. TXT "v=STSv1; id={{ sts_id }}" _mta-sts.{{ mail_domain }}. TXT "v=STSv1; id={{ sts_id }}"
mta-sts.{{ mail_domain }}. CNAME {{ mail_domain }}. mta-sts.{{ mail_domain }}. CNAME {{ mail_domain }}.
{% endif %}
www.{{ mail_domain }}. CNAME {{ mail_domain }}. www.{{ mail_domain }}. CNAME {{ mail_domain }}.
{{ dkim_entry }} {{ dkim_entry }}

View File

@@ -91,9 +91,10 @@ def run_cmd(args, out):
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain
sshexec = get_sshexec(ssh_host) sshexec = get_sshexec(ssh_host)
require_iroh = args.config.enable_iroh_relay require_iroh = args.config.enable_iroh_relay
strict_tls = args.config.tls_cert_mode == "acme"
if not args.dns_check_disabled: if not args.dns_check_disabled:
remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain) remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain)
if not dns.check_initial_remote_data(remote_data, print=out.red): if not dns.check_initial_remote_data(remote_data, strict_tls=strict_tls, print=out.red):
return 1 return 1
env = os.environ.copy() env = os.environ.copy()
@@ -101,6 +102,9 @@ 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"
@@ -121,7 +125,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 strict_tls 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
@@ -148,11 +152,13 @@ def dns_cmd(args, out):
"""Check DNS entries and optionally generate dns zone file.""" """Check DNS entries and optionally generate dns zone file."""
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain
sshexec = get_sshexec(ssh_host, verbose=args.verbose) sshexec = get_sshexec(ssh_host, verbose=args.verbose)
tls_cert_mode = args.config.tls_cert_mode
strict_tls = tls_cert_mode == "acme"
remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain) remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain)
if not remote_data: if not dns.check_initial_remote_data(remote_data, strict_tls=strict_tls):
return 1 return 1
if not remote_data["acme_account_url"]: if strict_tls and not remote_data["acme_account_url"]:
out.red("could not get letsencrypt account url, please run 'cmdeploy run'") out.red("could not get letsencrypt account url, please run 'cmdeploy run'")
return 1 return 1
@@ -160,6 +166,7 @@ def dns_cmd(args, out):
out.red("could not determine dkim_entry, please run 'cmdeploy run'") out.red("could not determine dkim_entry, please run 'cmdeploy run'")
return 1 return 1
remote_data["strict_tls"] = strict_tls
zonefile = dns.get_filled_zone_file(remote_data) zonefile = dns.get_filled_zone_file(remote_data)
if args.zonefile: if args.zonefile:
@@ -200,6 +207,7 @@ def test_cmd_options(parser):
action="store_true", action="store_true",
help="also run slow tests", help="also run slow tests",
) )
add_ssh_host_option(parser)
def test_cmd(args, out): def test_cmd(args, out):
@@ -211,6 +219,9 @@ def test_cmd(args, out):
x = importlib.util.find_spec("deltachat") x = importlib.util.find_spec("deltachat")
if x is None: if x is None:
out.check_call(f"{sys.executable} -m pip install deltachat") out.check_call(f"{sys.executable} -m pip install deltachat")
env = os.environ.copy()
if args.ssh_host:
env["CHATMAIL_SSH"] = args.ssh_host
pytest_path = shutil.which("pytest") pytest_path = shutil.which("pytest")
pytest_args = [ pytest_args = [
@@ -224,7 +235,7 @@ def test_cmd(args, out):
] ]
if args.slow: if args.slow:
pytest_args.append("--slow") pytest_args.append("--slow")
ret = out.run_ret(pytest_args) ret = out.run_ret(pytest_args, env=env)
return ret return ret

View File

@@ -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
@@ -18,6 +19,7 @@ from pyinfra.operations import apt, files, pip, server, systemd
from cmdeploy.cmdeploy import Out from cmdeploy.cmdeploy import Out
from .acmetool import AcmetoolDeployer from .acmetool import AcmetoolDeployer
from .selfsigned.deployer import SelfSignedTlsDeployer
from .basedeploy import ( from .basedeploy import (
Deployer, Deployer,
Deployment, Deployment,
@@ -36,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:
@@ -557,10 +559,21 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
line="\nnameserver 9.9.9.9", 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)
port_services = [ port_services = [
(["master", "smtpd"], 25), (["master", "smtpd"], 25),
("unbound", 53), ("unbound", 53),
("acmetool", 80), ]
if config.tls_cert_mode == "acme":
port_services.append(("acmetool", 80))
port_services += [
(["imap-login", "dovecot"], 143), (["imap-login", "dovecot"], 143),
("nginx", 443), ("nginx", 443),
(["master", "smtpd"], 465), (["master", "smtpd"], 465),
@@ -568,7 +581,7 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
(["imap-login", "dovecot"], 993), (["imap-login", "dovecot"], 993),
("iroh-relay", 3340), ("iroh-relay", 3340),
("mtail", 3903), ("mtail", 3903),
("dovecot-stats", 3904), ("stats", 3904),
("nginx", 8443), ("nginx", 8443),
(["master", "smtpd"], config.postfix_reinject_port), (["master", "smtpd"], config.postfix_reinject_port),
(["master", "smtpd"], config.postfix_reinject_port_incoming), (["master", "smtpd"], config.postfix_reinject_port_incoming),
@@ -578,8 +591,9 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
for service, port in port_services: for service, port in port_services:
print(f"Checking if port {port} is available for {service}...") print(f"Checking if port {port} is available for {service}...")
running_service = host.get_fact(Port, port=port) running_service = host.get_fact(Port, port=port)
services = [service] if isinstance(service, str) else service
if running_service: if running_service:
if running_service not in service: if running_service not in services:
Out().red( Out().red(
f"Deploy failed: port {port} is occupied by: {running_service}" f"Deploy failed: port {port} is occupied by: {running_service}"
) )
@@ -587,6 +601,11 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
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}"]
if config.tls_cert_mode == "acme":
tls_deployer = AcmetoolDeployer(config.acme_email, tls_domains)
else:
tls_deployer = SelfSignedTlsDeployer(mail_domain)
all_deployers = [ all_deployers = [
ChatmailDeployer(mail_domain), ChatmailDeployer(mail_domain),
LegacyRemoveDeployer(), LegacyRemoveDeployer(),
@@ -595,7 +614,7 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
UnboundDeployer(config), UnboundDeployer(config),
TurnDeployer(mail_domain), TurnDeployer(mail_domain),
IrohDeployer(config.enable_iroh_relay), IrohDeployer(config.enable_iroh_relay),
AcmetoolDeployer(config.acme_email, tls_domains), tls_deployer,
WebsiteDeployer(config), WebsiteDeployer(config),
ChatmailVenvDeployer(config), ChatmailVenvDeployer(config),
MtastsDeployer(), MtastsDeployer(),

View File

@@ -12,14 +12,14 @@ def get_initial_remote_data(sshexec, mail_domain):
) )
def check_initial_remote_data(remote_data, *, print=print): def check_initial_remote_data(remote_data, *, strict_tls=True, print=print):
mail_domain = remote_data["mail_domain"] mail_domain = remote_data["mail_domain"]
if not remote_data["A"] and not remote_data["AAAA"]: if not remote_data["A"] and not remote_data["AAAA"]:
print(f"Missing A and/or AAAA DNS records for {mail_domain}!") print(f"Missing A and/or AAAA DNS records for {mail_domain}!")
elif remote_data["MTA_STS"] != f"{mail_domain}.": elif strict_tls and remote_data["MTA_STS"] != f"{mail_domain}.":
print("Missing MTA-STS CNAME record:") print("Missing MTA-STS CNAME record:")
print(f"mta-sts.{mail_domain}. CNAME {mail_domain}.") print(f"mta-sts.{mail_domain}. CNAME {mail_domain}.")
elif remote_data["WWW"] != f"{mail_domain}.": elif strict_tls and remote_data["WWW"] != f"{mail_domain}.":
print("Missing www CNAME record:") print("Missing www CNAME record:")
print(f"www.{mail_domain}. CNAME {mail_domain}.") print(f"www.{mail_domain}. CNAME {mail_domain}.")
else: else:

View File

@@ -22,7 +22,7 @@ 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 not host.get_fact(SystemdEnabled).get("dovecot.service"):
_install_dovecot_package("core", arch) _install_dovecot_package("core", arch)
_install_dovecot_package("imapd", arch) _install_dovecot_package("imapd", arch)
_install_dovecot_package("lmtpd", arch) _install_dovecot_package("lmtpd", arch)

View File

@@ -68,11 +68,13 @@ userdb {
## ##
# Mailboxes are stored in the "mail" directory of the vmail user home. # Mailboxes are stored in the "mail" directory of the vmail user home.
{% if config.tmpfs_index %}
mail_location = maildir:{{ config.mailboxes_dir }}/%u:INDEX=/dev/shm/%u
{% else %}
mail_location = maildir:{{ config.mailboxes_dir }}/%u mail_location = maildir:{{ config.mailboxes_dir }}/%u
{% endif %}
# index/cache files are not very useful for chatmail relay operations
# but it's not clear how to disable them completely.
# According to https://doc.dovecot.org/2.3/settings/advanced/#core_setting-mail_cache_max_size
# if the cache file becomes larger than the specified size, it is truncated by dovecot
mail_cache_max_size = 500K
namespace inbox { namespace inbox {
inbox = yes inbox = yes
@@ -226,8 +228,8 @@ service anvil {
} }
ssl = required ssl = required
ssl_cert = </var/lib/acme/live/{{ config.mail_domain }}/fullchain ssl_cert = <{{ config.tls_cert_path }}
ssl_key = </var/lib/acme/live/{{ config.mail_domain }}/privkey ssl_key = <{{ config.tls_key_path }}
ssl_dh = </usr/share/dovecot/dh.pem ssl_dh = </usr/share/dovecot/dh.pem
ssl_min_protocol = TLSv1.3 ssl_min_protocol = TLSv1.3
ssl_prefer_server_ciphers = yes ssl_prefer_server_ciphers = yes

View File

@@ -14,10 +14,10 @@ class FiltermailDeployer(Deployer):
def install(self): def install(self):
arch = host.get_fact(facts.server.Arch) arch = host.get_fact(facts.server.Arch)
url = f"https://github.com/chatmail/filtermail/releases/download/v0.2.0/filtermail-{arch}-musl" url = f"https://github.com/chatmail/filtermail/releases/download/v0.3.0/filtermail-{arch}"
sha256sum = { sha256sum = {
"x86_64": "1e5bbb646582cb16740c6dfbbca39edba492b78cc96ec9fa2528c612bb504edd", "x86_64": "f14a31323ae2dad3b59d3fdafcde507521da2f951a9478cd1f2fe2b4463df71d",
"aarch64": "3564fba8605f8f9adfeefff3f4580533205da043f47c5968d0d10db17e50f44e", "aarch64": "933770d75046c4fd7084ce8d43f905f8748333426ad839154f0fc654755ef09f",
}[arch] }[arch]
self.need_restart |= files.download( self.need_restart |= files.download(
name="Download filtermail", name="Download filtermail",

View File

@@ -1,47 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<clientConfig version="1.1"> <clientConfig version="1.1">
<emailProvider id="{{ config.domain_name }}"> <emailProvider id="{{ config.mail_domain }}">
<domain>{{ config.domain_name }}</domain> <domain>{{ config.mail_domain }}</domain>
<displayName>{{ config.domain_name }} chatmail</displayName> <displayName>{{ config.mail_domain }} chatmail</displayName>
<displayShortName>{{ config.domain_name }}</displayShortName> <displayShortName>{{ config.mail_domain }}</displayShortName>
<incomingServer type="imap"> <incomingServer type="imap">
<hostname>{{ config.domain_name }}</hostname> <hostname>{{ config.mail_domain }}</hostname>
<port>993</port> <port>993</port>
<socketType>SSL</socketType> <socketType>SSL</socketType>
<authentication>password-cleartext</authentication> <authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username> <username>%EMAILADDRESS%</username>
</incomingServer> </incomingServer>
<incomingServer type="imap"> <incomingServer type="imap">
<hostname>{{ config.domain_name }}</hostname> <hostname>{{ config.mail_domain }}</hostname>
<port>143</port> <port>143</port>
<socketType>STARTTLS</socketType> <socketType>STARTTLS</socketType>
<authentication>password-cleartext</authentication> <authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username> <username>%EMAILADDRESS%</username>
</incomingServer> </incomingServer>
<incomingServer type="imap"> <incomingServer type="imap">
<hostname>{{ config.domain_name }}</hostname> <hostname>{{ config.mail_domain }}</hostname>
<port>443</port> <port>443</port>
<socketType>SSL</socketType> <socketType>SSL</socketType>
<authentication>password-cleartext</authentication> <authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username> <username>%EMAILADDRESS%</username>
</incomingServer> </incomingServer>
<outgoingServer type="smtp"> <outgoingServer type="smtp">
<hostname>{{ config.domain_name }}</hostname> <hostname>{{ config.mail_domain }}</hostname>
<port>465</port> <port>465</port>
<socketType>SSL</socketType> <socketType>SSL</socketType>
<authentication>password-cleartext</authentication> <authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username> <username>%EMAILADDRESS%</username>
</outgoingServer> </outgoingServer>
<outgoingServer type="smtp"> <outgoingServer type="smtp">
<hostname>{{ config.domain_name }}</hostname> <hostname>{{ config.mail_domain }}</hostname>
<port>587</port> <port>587</port>
<socketType>STARTTLS</socketType> <socketType>STARTTLS</socketType>
<authentication>password-cleartext</authentication> <authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username> <username>%EMAILADDRESS%</username>
</outgoingServer> </outgoingServer>
<outgoingServer type="smtp"> <outgoingServer type="smtp">
<hostname>{{ config.domain_name }}</hostname> <hostname>{{ config.mail_domain }}</hostname>
<port>443</port> <port>443</port>
<socketType>SSL</socketType> <socketType>SSL</socketType>
<authentication>password-cleartext</authentication> <authentication>password-cleartext</authentication>

View File

@@ -70,7 +70,7 @@ def _configure_nginx(config: Config, debug: bool = False) -> bool:
user="root", user="root",
group="root", group="root",
mode="644", mode="644",
config={"domain_name": config.mail_domain}, config=config,
disable_ipv6=config.disable_ipv6, disable_ipv6=config.disable_ipv6,
) )
need_restart |= main_config.changed need_restart |= main_config.changed
@@ -81,7 +81,7 @@ def _configure_nginx(config: Config, debug: bool = False) -> bool:
user="root", user="root",
group="root", group="root",
mode="644", mode="644",
config={"domain_name": config.mail_domain}, config=config,
) )
need_restart |= autoconfig.changed need_restart |= autoconfig.changed
@@ -91,7 +91,7 @@ def _configure_nginx(config: Config, debug: bool = False) -> bool:
user="root", user="root",
group="root", group="root",
mode="644", mode="644",
config={"domain_name": config.mail_domain}, config=config,
) )
need_restart |= mta_sts_config.changed need_restart |= mta_sts_config.changed

View File

@@ -1,4 +1,4 @@
version: STSv1 version: STSv1
mode: enforce mode: enforce
mx: {{ config.domain_name }} mx: {{ config.mail_domain }}
max_age: 2419200 max_age: 2419200

View File

@@ -42,6 +42,9 @@ stream {
} }
http { http {
{% if config.tls_cert_mode == "self" %}
limit_req_zone $binary_remote_addr zone=newaccount:10m rate=2r/s;
{% endif %}
sendfile on; sendfile on;
tcp_nopush on; tcp_nopush on;
@@ -53,8 +56,8 @@ http {
ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on; ssl_prefer_server_ciphers on;
ssl_certificate /var/lib/acme/live/{{ config.domain_name }}/fullchain; ssl_certificate {{ config.tls_cert_path }};
ssl_certificate_key /var/lib/acme/live/{{ config.domain_name }}/privkey; ssl_certificate_key {{ config.tls_key_path }};
gzip on; gzip on;
@@ -66,7 +69,7 @@ http {
index index.html index.htm; index index.html index.htm;
server_name {{ config.domain_name }} www.{{ config.domain_name }} mta-sts.{{ config.domain_name }}; server_name {{ config.mail_domain }} www.{{ config.mail_domain }} mta-sts.{{ config.mail_domain }};
access_log syslog:server=unix:/dev/log,facility=local7; access_log syslog:server=unix:/dev/log,facility=local7;
@@ -81,11 +84,15 @@ http {
} }
location /new { location /new {
{% if config.tls_cert_mode == "acme" %}
if ($request_method = GET) { if ($request_method = GET) {
# Redirect to Delta Chat, # Redirect to Delta Chat,
# which will in turn do a POST request. # which will in turn do a POST request.
return 301 dcaccount:https://{{ config.domain_name }}/new; return 301 dcaccount:https://{{ config.mail_domain }}/new;
} }
{% else %}
limit_req zone=newaccount burst=5 nodelay;
{% endif %}
fastcgi_pass unix:/run/fcgiwrap.socket; fastcgi_pass unix:/run/fcgiwrap.socket;
include /etc/nginx/fastcgi_params; include /etc/nginx/fastcgi_params;
@@ -99,9 +106,11 @@ http {
# #
# Redirects are only for browsers. # Redirects are only for browsers.
location /cgi-bin/newemail.py { location /cgi-bin/newemail.py {
{% if config.tls_cert_mode == "acme" %}
if ($request_method = GET) { if ($request_method = GET) {
return 301 dcaccount:https://{{ config.domain_name }}/new; return 301 dcaccount:https://{{ config.mail_domain }}/new;
} }
{% endif %}
fastcgi_pass unix:/run/fcgiwrap.socket; fastcgi_pass unix:/run/fcgiwrap.socket;
include /etc/nginx/fastcgi_params; include /etc/nginx/fastcgi_params;
@@ -132,8 +141,8 @@ http {
# Redirect www. to non-www # Redirect www. to non-www
server { server {
listen 127.0.0.1:8443 ssl; listen 127.0.0.1:8443 ssl;
server_name www.{{ config.domain_name }}; server_name www.{{ config.mail_domain }};
return 301 $scheme://{{ config.domain_name }}$request_uri; return 301 $scheme://{{ config.mail_domain }}$request_uri;
access_log syslog:server=unix:/dev/log,facility=local7; access_log syslog:server=unix:/dev/log,facility=local7;
} }
} }

View File

@@ -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"),

View File

@@ -1,2 +1,3 @@
/^DKIM-Signature:/ IGNORE /^DKIM-Signature:/ IGNORE
/^Authentication-Results:/ IGNORE /^Authentication-Results:/ IGNORE
/^Received:/ IGNORE

View File

@@ -15,17 +15,17 @@ readme_directory = no
compatibility_level = 3.6 compatibility_level = 3.6
# TLS parameters # TLS parameters
smtpd_tls_cert_file=/var/lib/acme/live/{{ config.mail_domain }}/fullchain smtpd_tls_cert_file={{ config.tls_cert_path }}
smtpd_tls_key_file=/var/lib/acme/live/{{ config.mail_domain }}/privkey smtpd_tls_key_file={{ config.tls_key_path }}
smtpd_tls_security_level=may smtpd_tls_security_level=may
smtp_tls_CApath=/etc/ssl/certs smtp_tls_CApath=/etc/ssl/certs
smtp_tls_security_level=verify smtp_tls_security_level={{ "verify" if config.tls_cert_mode == "acme" else "encrypt" }}
# Send SNI extension when connecting to other servers. # Send SNI extension when connecting to other servers.
# <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
@@ -69,6 +69,15 @@ mynetworks = 127.0.0.0/8
{% else %} {% 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 %} {% 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 = +

View File

@@ -0,0 +1,3 @@
/^\[[^]]+\]$/ encrypt
/^_/ encrypt
/^nauta\.cu$/ may

View File

@@ -0,0 +1,36 @@
from pyinfra.operations import apt, files, server
from cmdeploy.basedeploy import Deployer
class SelfSignedTlsDeployer(Deployer):
"""Generates a self-signed TLS certificate for all chatmail endpoints."""
def __init__(self, mail_domain):
self.mail_domain = mail_domain
self.cert_path = "/etc/ssl/certs/mailserver.pem"
self.key_path = "/etc/ssl/private/mailserver.key"
def install(self):
apt.packages(
name="Install openssl",
packages=["openssl"],
)
def configure(self):
server.shell(
name="Generate self-signed TLS certificate if not present",
commands=[
f"[ -f {self.cert_path} ] || openssl req -x509"
f" -newkey ec -pkeyopt ec_paramgen_curve:P-256"
f" -noenc -days 36500"
f" -keyout {self.key_path}"
f" -out {self.cert_path}"
f' -subj "/CN={self.mail_domain}"'
f' -addext "extendedKeyUsage=serverAuth,clientAuth"'
f' -addext "subjectAltName=DNS:{self.mail_domain},DNS:www.{self.mail_domain},DNS:mta-sts.{self.mail_domain}"',
],
)
def activate(self):
pass

View File

@@ -4,7 +4,7 @@ Description=Chatmail dict proxy for IMAP METADATA
[Service] [Service]
ExecStart={execpath} /run/chatmail-metadata/metadata.socket {config_path} ExecStart={execpath} /run/chatmail-metadata/metadata.socket {config_path}
Restart=always Restart=always
RestartSec=30 RestartSec=5
User=vmail User=vmail
RuntimeDirectory=chatmail-metadata RuntimeDirectory=chatmail-metadata
UMask=0077 UMask=0077

View File

@@ -85,16 +85,31 @@ class SSHExec:
class LocalExec: class LocalExec:
FuncError = FuncError
def __init__(self, verbose=False, docker=False): def __init__(self, verbose=False, docker=False):
self.verbose = verbose self.verbose = verbose
self.docker = docker self.docker = docker
def __call__(self, call, kwargs=None, log_callback=None):
if kwargs is None:
kwargs = {}
return call(**kwargs)
def logged(self, call, kwargs: dict): def logged(self, call, kwargs: dict):
title = call.__doc__
if not title:
title = call.__name__
where = "locally" where = "locally"
if self.docker: if self.docker:
if call == remote.rdns.perform_initial_checks: if call == remote.rdns.perform_initial_checks:
kwargs["pre_command"] = "docker exec chatmail " kwargs["pre_command"] = "docker exec chatmail "
where = "in docker" where = "in docker"
if self.verbose: if self.verbose:
print(f"Running {where}: {call.__name__}(**{kwargs})") print_stderr(f"Running {where}: {title}(**{kwargs})")
return call(**kwargs) return self(call, kwargs, log_callback=print_stderr)
else:
print_stderr(title, end="")
res = self(call, kwargs, log_callback=remote.rshell.log_progress)
print_stderr()
return res

View File

@@ -1,3 +1,4 @@
import pytest
import requests import requests
from cmdeploy.genqr import gen_qr_png_data from cmdeploy.genqr import gen_qr_png_data
@@ -8,18 +9,33 @@ def test_gen_qr_png_data(maildomain):
assert data assert data
@pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning")
def test_fastcgi_working(maildomain, chatmail_config): def test_fastcgi_working(maildomain, chatmail_config):
url = f"https://{maildomain}/new" url = f"https://{maildomain}/new"
print(url) print(url)
res = requests.post(url) verify = chatmail_config.tls_cert_mode == "acme"
res = requests.post(url, verify=verify)
assert maildomain in res.json().get("email") assert maildomain in res.json().get("email")
assert len(res.json().get("password")) > chatmail_config.password_min_length assert len(res.json().get("password")) > chatmail_config.password_min_length
def test_newemail_configure(maildomain, rpc): @pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning")
def test_newemail_configure(maildomain, rpc, chatmail_config):
"""Test configuring accounts by scanning a QR code works.""" """Test configuring accounts by scanning a QR code works."""
url = f"DCACCOUNT:https://{maildomain}/new" url = f"DCACCOUNT:https://{maildomain}/new"
for i in range(3): for i in range(3):
account_id = rpc.add_account() account_id = rpc.add_account()
rpc.set_config_from_qr(account_id, url) if chatmail_config.tls_cert_mode == "self":
rpc.configure(account_id) # deltachat core's rustls rejects self-signed HTTPS certs during
# set_config_from_qr, so fetch credentials via requests instead
res = requests.post(f"https://{maildomain}/new", verify=False)
data = res.json()
rpc.add_or_update_transport(account_id, {
"addr": data["email"],
"password": data["password"],
"imapServer": maildomain,
"smtpServer": maildomain,
"certificateChecks": "acceptInvalidCertificates",
})
else:
rpc.add_transport_from_qr(account_id, url)

View File

@@ -7,13 +7,13 @@ import time
import pytest import pytest
from cmdeploy import remote from cmdeploy import remote
from cmdeploy.sshexec import SSHExec from cmdeploy.cmdeploy import get_sshexec
class TestSSHExecutor: class TestSSHExecutor:
@pytest.fixture(scope="class") @pytest.fixture(scope="class")
def sshexec(self, sshdomain): def sshexec(self, sshdomain):
return SSHExec(sshdomain) return get_sshexec(sshdomain)
def test_ls(self, sshexec): def test_ls(self, sshexec):
out = sshexec(call=remote.rdns.shell, kwargs=dict(command="ls")) out = sshexec(call=remote.rdns.shell, kwargs=dict(command="ls"))
@@ -27,6 +27,7 @@ class TestSSHExecutor:
assert res["A"] or res["AAAA"] assert res["A"] or res["AAAA"]
def test_logged(self, sshexec, maildomain, capsys): def test_logged(self, sshexec, maildomain, capsys):
sshexec.verbose = False
sshexec.logged( sshexec.logged(
remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=maildomain) remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=maildomain)
) )
@@ -52,6 +53,8 @@ class TestSSHExecutor:
remote.rdns.perform_initial_checks, remote.rdns.perform_initial_checks,
kwargs=dict(mail_domain=None), kwargs=dict(mail_domain=None),
) )
except AssertionError:
pass
except sshexec.FuncError as e: except sshexec.FuncError as e:
assert "rdns.py" in str(e) assert "rdns.py" in str(e)
assert "AssertionError" in str(e) assert "AssertionError" in str(e)
@@ -218,7 +221,7 @@ def test_expunged(remote, chatmail_config):
] ]
outdated_days = int(chatmail_config.delete_large_after) + 1 outdated_days = int(chatmail_config.delete_large_after) + 1
find_cmds.append( find_cmds.append(
"find {chatmail_config.mailboxes_dir} -path '*/cur/*' -mtime +{outdated_days} -size +200k -type f" f"find {chatmail_config.mailboxes_dir} -path '*/cur/*' -mtime +{outdated_days} -size +200k -type f"
) )
for cmd in find_cmds: for cmd in find_cmds:
for line in remote.iter_output(cmd): for line in remote.iter_output(cmd):

View File

@@ -7,15 +7,16 @@ import pytest
import requests import requests
from cmdeploy.remote import rshell from cmdeploy.remote import rshell
from cmdeploy.sshexec import SSHExec from cmdeploy.cmdeploy import get_sshexec
@pytest.fixture @pytest.fixture
def imap_mailbox(cmfactory): def imap_mailbox(cmfactory, ssl_context):
(ac1,) = cmfactory.get_online_accounts(1) (ac1,) = cmfactory.get_online_accounts(1)
user = ac1.get_config("addr") user = ac1.get_config("addr")
password = ac1.get_config("mail_pw") password = ac1.get_config("mail_pw")
mailbox = imap_tools.MailBox(user.split("@")[1]) host = user.split("@")[1]
mailbox = imap_tools.MailBox(host, ssl_context=ssl_context)
mailbox.login(user, password) mailbox.login(user, password)
mailbox.dc_ac = ac1 mailbox.dc_ac = ac1
return mailbox return mailbox
@@ -90,7 +91,7 @@ class TestEndToEndDeltaChat:
lp.sec(f"filling remote inbox for {user}") lp.sec(f"filling remote inbox for {user}")
fn = f"7743102289.M843172P2484002.c20,S={quota},W=2398:2," fn = f"7743102289.M843172P2484002.c20,S={quota},W=2398:2,"
path = chatmail_config.mailboxes_dir.joinpath(user, "cur", fn) path = chatmail_config.mailboxes_dir.joinpath(user, "cur", fn)
sshexec = SSHExec(sshdomain) sshexec = get_sshexec(sshdomain)
sshexec(call=rshell.write_numbytes, kwargs=dict(path=str(path), num=120)) sshexec(call=rshell.write_numbytes, kwargs=dict(path=str(path), num=120))
res = sshexec(call=rshell.dovecot_recalc_quota, kwargs=dict(user=user)) res = sshexec(call=rshell.dovecot_recalc_quota, kwargs=dict(user=user))
assert res["percent"] >= 100 assert res["percent"] >= 100
@@ -171,7 +172,7 @@ class TestEndToEndDeltaChat:
time.sleep(1) time.sleep(1)
def test_hide_senders_ip_address(cmfactory): def test_hide_senders_ip_address(cmfactory, ssl_context):
public_ip = requests.get("http://icanhazip.com").content.decode().strip() public_ip = requests.get("http://icanhazip.com").content.decode().strip()
assert ipaddress.ip_address(public_ip) assert ipaddress.ip_address(public_ip)
@@ -180,6 +181,11 @@ def test_hide_senders_ip_address(cmfactory):
chat.send_text("testing submission header cleanup") chat.send_text("testing submission header cleanup")
user2._evtracker.wait_next_incoming_message() user2._evtracker.wait_next_incoming_message()
user2.direct_imap.select_folder("Inbox") addr = user2.get_config("addr")
msg = user2.direct_imap.get_all_messages()[0] host = addr.split("@")[1]
assert public_ip not in msg.obj.as_string() pw = user2.get_config("mail_pw")
mailbox = imap_tools.MailBox(host, ssl_context=ssl_context)
mailbox.login(addr, pw)
msgs = list(mailbox.fetch(mark_seen=False))
assert msgs, "expected at least one message"
assert public_ip not in msgs[0].obj.as_string()

View File

@@ -5,7 +5,11 @@ from cmdeploy.cmdeploy import main
def test_status_cmd(chatmail_config, capsys, request): def test_status_cmd(chatmail_config, capsys, request):
os.chdir(request.config.invocation_params.dir) os.chdir(request.config.invocation_params.dir)
assert main(["status"]) == 0 command = ["status"]
if os.getenv("CHATMAIL_SSH"):
command.append("--ssh-host")
command.append(os.getenv("CHATMAIL_SSH"))
assert main(command) == 0
status_out = capsys.readouterr() status_out = capsys.readouterr()
print(status_out.out) print(status_out.out)

View File

@@ -4,6 +4,7 @@ import itertools
import os import os
import random import random
import smtplib import smtplib
import ssl
import subprocess import subprocess
import time import time
from pathlib import Path from pathlib import Path
@@ -144,15 +145,25 @@ def pytest_terminal_summary(terminalreporter):
tr.write_line(line) tr.write_line(line)
@pytest.fixture @pytest.fixture(scope="session")
def imap(maildomain): def ssl_context(chatmail_config):
return ImapConn(maildomain) if chatmail_config.tls_cert_mode == "self":
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
return None
@pytest.fixture @pytest.fixture
def make_imap_connection(maildomain): def imap(maildomain, ssl_context):
return ImapConn(maildomain, ssl_context=ssl_context)
@pytest.fixture
def make_imap_connection(maildomain, ssl_context):
def make_imap_connection(): def make_imap_connection():
conn = ImapConn(maildomain) conn = ImapConn(maildomain, ssl_context=ssl_context)
conn.connect() conn.connect()
return conn return conn
@@ -164,12 +175,13 @@ class ImapConn:
logcmd = "journalctl -f -u dovecot" logcmd = "journalctl -f -u dovecot"
name = "dovecot" name = "dovecot"
def __init__(self, host): def __init__(self, host, ssl_context=None):
self.host = host self.host = host
self.ssl_context = ssl_context
def connect(self): def connect(self):
print(f"imap-connect {self.host}") print(f"imap-connect {self.host}")
self.conn = imaplib.IMAP4_SSL(self.host) self.conn = imaplib.IMAP4_SSL(self.host, ssl_context=self.ssl_context)
def login(self, user, password): def login(self, user, password):
print(f"imap-login {user!r} {password!r}") print(f"imap-login {user!r} {password!r}")
@@ -195,14 +207,14 @@ class ImapConn:
@pytest.fixture @pytest.fixture
def smtp(maildomain): def smtp(maildomain, ssl_context):
return SmtpConn(maildomain) return SmtpConn(maildomain, ssl_context=ssl_context)
@pytest.fixture @pytest.fixture
def make_smtp_connection(maildomain): def make_smtp_connection(maildomain, ssl_context):
def make_smtp_connection(): def make_smtp_connection():
conn = SmtpConn(maildomain) conn = SmtpConn(maildomain, ssl_context=ssl_context)
conn.connect() conn.connect()
return conn return conn
@@ -214,12 +226,14 @@ class SmtpConn:
logcmd = "journalctl -f -t postfix/smtpd -t postfix/smtp -t postfix/lmtp" logcmd = "journalctl -f -t postfix/smtpd -t postfix/smtp -t postfix/lmtp"
name = "postfix" name = "postfix"
def __init__(self, host): def __init__(self, host, ssl_context=None):
self.host = host self.host = host
self.ssl_context = ssl_context
def connect(self): def connect(self):
print(f"smtp-connect {self.host}") print(f"smtp-connect {self.host}")
self.conn = smtplib.SMTP_SSL(self.host) context = self.ssl_context or ssl.create_default_context()
self.conn = smtplib.SMTP_SSL(self.host, context=context)
def login(self, user, password): def login(self, user, password):
print(f"smtp-login {user!r} {password!r}") print(f"smtp-login {user!r} {password!r}")
@@ -270,11 +284,12 @@ def gencreds(chatmail_config):
class ChatmailTestProcess: class ChatmailTestProcess:
"""Provider for chatmail instance accounts as used by deltachat.testplugin.acfactory""" """Provider for chatmail instance accounts as used by deltachat.testplugin.acfactory"""
def __init__(self, pytestconfig, maildomain, gencreds): def __init__(self, pytestconfig, maildomain, gencreds, chatmail_config):
self.pytestconfig = pytestconfig self.pytestconfig = pytestconfig
self.maildomain = maildomain self.maildomain = maildomain
assert "." in self.maildomain, maildomain assert "." in self.maildomain, maildomain
self.gencreds = gencreds self.gencreds = gencreds
self.chatmail_config = chatmail_config
self._addr2files = {} self._addr2files = {}
def get_liveconfig_producer(self): def get_liveconfig_producer(self):
@@ -287,6 +302,9 @@ class ChatmailTestProcess:
# speed up account configuration # speed up account configuration
config["mail_server"] = self.maildomain config["mail_server"] = self.maildomain
config["send_server"] = self.maildomain config["send_server"] = self.maildomain
if self.chatmail_config.tls_cert_mode == "self":
# Accept self-signed TLS certificates
config["imap_certificate_checks"] = "3"
yield config yield config
def cache_maybe_retrieve_configured_db_files(self, cache_addr, db_target_path): def cache_maybe_retrieve_configured_db_files(self, cache_addr, db_target_path):
@@ -297,12 +315,14 @@ class ChatmailTestProcess:
@pytest.fixture @pytest.fixture
def cmfactory(request, gencreds, tmpdir, maildomain): def cmfactory(request, gencreds, tmpdir, maildomain, chatmail_config):
# cloned from deltachat.testplugin.amfactory # cloned from deltachat.testplugin.amfactory
pytest.importorskip("deltachat") pytest.importorskip("deltachat")
from deltachat.testplugin import ACFactory from deltachat.testplugin import ACFactory
testproc = ChatmailTestProcess(request.config, maildomain, gencreds) testproc = ChatmailTestProcess(
request.config, maildomain, gencreds, chatmail_config
)
class Data: class Data:
def read_path(self, path): def read_path(self, path):
@@ -310,6 +330,10 @@ def cmfactory(request, gencreds, tmpdir, maildomain):
am = ACFactory(request=request, tmpdir=tmpdir, testprocess=testproc, data=Data()) am = ACFactory(request=request, tmpdir=tmpdir, testprocess=testproc, data=Data())
# Skip upstream's init_imap to prevent extra imap connections not
# needed for relay testing
am._acsetup.init_imap = lambda acc: None
# nb. a bit hacky # nb. a bit hacky
# would probably be better if deltachat's test machinery grows native support # would probably be better if deltachat's test machinery grows native support
def switch_maildomain(maildomain2): def switch_maildomain(maildomain2):
@@ -337,8 +361,14 @@ class Remote:
def iter_output(self, logcmd=""): def iter_output(self, logcmd=""):
getjournal = "journalctl -f" if not logcmd else logcmd getjournal = "journalctl -f" if not logcmd else logcmd
print(self.sshdomain)
match self.sshdomain:
case "@local": command = []
case "localhost": command = []
case _: command = ["ssh", f"root@{self.sshdomain}"]
[command.append(arg) for arg in getjournal.split()]
self.popen = subprocess.Popen( self.popen = subprocess.Popen(
["ssh", f"root@{self.sshdomain}", getjournal], command,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
) )
while 1: while 1:
@@ -363,38 +393,40 @@ def lp(request):
@pytest.fixture @pytest.fixture
def cmsetup(maildomain, gencreds): def cmsetup(maildomain, gencreds, ssl_context):
return CMSetup(maildomain, gencreds) return CMSetup(maildomain, gencreds, ssl_context)
class CMSetup: class CMSetup:
def __init__(self, maildomain, gencreds): def __init__(self, maildomain, gencreds, ssl_context):
self.maildomain = maildomain self.maildomain = maildomain
self.gencreds = gencreds self.gencreds = gencreds
self.ssl_context = ssl_context
def gen_users(self, num): def gen_users(self, num):
print(f"Creating {num} online users") print(f"Creating {num} online users")
users = [] users = []
for i in range(num): for i in range(num):
addr, password = self.gencreds() addr, password = self.gencreds()
user = CMUser(self.maildomain, addr, password) user = CMUser(self.maildomain, addr, password, self.ssl_context)
assert user.smtp assert user.smtp
users.append(user) users.append(user)
return users return users
class CMUser: class CMUser:
def __init__(self, maildomain, addr, password): def __init__(self, maildomain, addr, password, ssl_context=None):
self.maildomain = maildomain self.maildomain = maildomain
self.addr = addr self.addr = addr
self.password = password self.password = password
self.ssl_context = ssl_context
self._smtp = None self._smtp = None
self._imap = None self._imap = None
@property @property
def smtp(self): def smtp(self):
if not self._smtp: if not self._smtp:
handle = SmtpConn(self.maildomain) handle = SmtpConn(self.maildomain, ssl_context=self.ssl_context)
handle.connect() handle.connect()
handle.login(self.addr, self.password) handle.login(self.addr, self.password)
self._smtp = handle self._smtp = handle
@@ -403,7 +435,7 @@ class CMUser:
@property @property
def imap(self): def imap(self):
if not self._imap: if not self._imap:
imap = ImapConn(self.maildomain) imap = ImapConn(self.maildomain, ssl_context=self.ssl_context)
imap.connect() imap.connect()
imap.login(self.addr, self.password) imap.login(self.addr, self.password)
self._imap = imap self._imap = imap

View File

@@ -91,6 +91,16 @@ class TestPerformInitialChecks:
assert not res assert not res
assert len(l) == 2 assert len(l) == 2
def test_perform_initial_checks_no_mta_sts_self_signed(self, mockdns):
del mockdns["CNAME"]["mta-sts.some.domain"]
remote_data = remote.rdns.perform_initial_checks("some.domain")
assert not remote_data["MTA_STS"]
l = []
res = check_initial_remote_data(remote_data, strict_tls=False, print=l.append)
assert res
assert not l
def parse_zonefile_into_dict(zonefile, mockdns_base, only_required=False): def parse_zonefile_into_dict(zonefile, mockdns_base, only_required=False):
for zf_line in zonefile.split("\n"): for zf_line in zonefile.split("\n"):

View File

@@ -47,6 +47,14 @@ steps. Please substitute it with your own domain.
www.chat.example.org. 3600 IN CNAME chat.example.org. www.chat.example.org. 3600 IN CNAME chat.example.org.
mta-sts.chat.example.org. 3600 IN CNAME chat.example.org. mta-sts.chat.example.org. 3600 IN CNAME chat.example.org.
.. note::
For experimental deployments using self-signed certificates,
use a domain name starting with ``_``
(e.g. ``_chat.example.org``).
The ``mta-sts`` CNAME and ``_mta-sts`` TXT records
are not needed for such domains.
2. On your local PC, clone the repository and bootstrap the Python 2. On your local PC, clone the repository and bootstrap the Python
virtualenv. virtualenv.
@@ -63,6 +71,16 @@ steps. Please substitute it with your own domain.
scripts/cmdeploy init chat.example.org # <-- use your domain scripts/cmdeploy init chat.example.org # <-- use your domain
To use self-signed TLS certificates
instead of Let's Encrypt,
use a domain name starting with ``_``
(e.g. ``scripts/cmdeploy init _chat.example.org``).
Domains starting with ``_`` cannot obtain WebPKI certificates,
so self-signed mode is derived automatically.
This is useful for private or test deployments.
See the :doc:`overview`
for details on certificate provisioning.
4. Verify that SSH root login to the deployment server server works: 4. Verify that SSH root login to the deployment server server works:
:: ::
@@ -169,6 +187,17 @@ creating addresses, login with ssh to the deployment machine and run:
Chatmail address creation will be denied while this file is present. Chatmail address creation will be denied while this file is present.
Running a relay with self-signed certificates
----------------------------------------------
Use a domain name starting with ``_`` (e.g. ``_chat.example.org``)
to run a relay with self-signed certificates.
Domains starting with ``_`` cannot obtain WebPKI certificates
so the relay automatically uses self-signed certificates
and all other relays will accept connections from it
without requiring certificate verification.
This is useful for experimental setups and testing.
Migrating to a new build machine Migrating to a new build machine
---------------------------------- ----------------------------------

View File

@@ -297,8 +297,7 @@ TLS requirements
Postfix is configured to require valid TLS by setting Postfix is configured to require valid TLS by setting
`smtp_tls_security_level <https://www.postfix.org/postconf.5.html#smtp_tls_security_level>`_ `smtp_tls_security_level <https://www.postfix.org/postconf.5.html#smtp_tls_security_level>`_
to ``verify``. If emails dont arrive at your chatmail relay server, the to ``verify``.
problem is likely that your relay does not have a valid TLS certificate.
You can test it by resolving ``MX`` records of your relay domain and You can test it by resolving ``MX`` records of your relay domain and
then connecting to MX relays (e.g ``mx.example.org``) with then connecting to MX relays (e.g ``mx.example.org``) with
@@ -317,6 +316,14 @@ default Exim does not log sessions that are closed before sending the
by Postfix, so you might think that connection is not established while by Postfix, so you might think that connection is not established while
actually it is a problem with your TLS certificate. actually it is a problem with your TLS certificate.
If emails dont arrive at your chatmail relay server, the
problem is likely that your relay does not have a valid TLS certificate.
Note that connections to relays with underscore-prefixed test domains
(e.g. ``_chat.example.org``) use ``encrypt`` tls security level,
because such domains cannot obtain valid Let's Encrypt certificates
and run with self-signed certificates.
.. _dovecot: https://dovecot.org .. _dovecot: https://dovecot.org
.. _postfix: https://www.postfix.org .. _postfix: https://www.postfix.org

View File

@@ -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.

21
www/src/dclogin.js Normal file
View File

@@ -0,0 +1,21 @@
/* dclogin profile generator for self-signed chatmail relays.
* Fetches credentials from /new and generates a dclogin: QR code.
* Requires qrcode-svg.min.js to be loaded first.
*/
(function () {
function generateProfile() {
fetch('/new')
.then(function (r) { return r.json(); })
.then(function (data) {
var url = data.dclogin_url;
var link = document.getElementById('dclogin-link');
link.href = url;
var qrLink = document.getElementById('qr-link');
qrLink.href = url;
var qrCode = document.getElementById('qr-code');
var qr = new QRCode({ content: url, width: 300, height: 300, padding: 1, join: true });
qrCode.innerHTML = qr.svg();
});
}
generateProfile();
})();

View File

@@ -11,6 +11,18 @@ for Delta Chat users. For details how it avoids storing personal information
please see our [privacy policy](privacy.html). please see our [privacy policy](privacy.html).
{% endif %} {% endif %}
{% if config.tls_cert_mode == "self" %}
<a class="cta-button" id="dclogin-link" href="#">Get a {{config.mail_domain}} chat profile</a>
If you are viewing this page on a different device
without a Delta Chat app,
you can also **scan this QR code** with Delta Chat:
<a id="qr-link" href="#"><div id="qr-code"></div></a>
<script src="qrcode-svg.min.js"></script>
<script src="dclogin.js"></script>
{% else %}
<a class="cta-button" href="DCACCOUNT:https://{{ config.mail_domain }}/new">Get a {{config.mail_domain}} chat profile</a> <a class="cta-button" href="DCACCOUNT:https://{{ config.mail_domain }}/new">Get a {{config.mail_domain}} chat profile</a>
If you are viewing this page on a different device If you are viewing this page on a different device
@@ -19,6 +31,7 @@ you can also **scan this QR code** with Delta Chat:
<a href="DCACCOUNT:https://{{ config.mail_domain }}/new"> <a href="DCACCOUNT:https://{{ config.mail_domain }}/new">
<img width=300 style="float: none;" src="qr-chatmail-invite-{{config.mail_domain}}.png" /></a> <img width=300 style="float: none;" src="qr-chatmail-invite-{{config.mail_domain}}.png" /></a>
{% endif %}
🐣 **Choose** your Avatar and Name 🐣 **Choose** your Avatar and Name

9
www/src/qrcode-svg.min.js vendored Normal file

File diff suppressed because one or more lines are too long