Compare commits

...

21 Commits
1.10.0 ... main

Author SHA1 Message Date
missytake
ed664cd9cd feat(config): load default values from Config(), not chatmail.ini.f (#853)
* config: comment out values in chatmail.ini.f, so defaults take precedence
* config: remove testrun-specific overrides
* config: remove filtermail ports from default ini
2026-05-12 22:44:06 +02:00
holger krekel
26a13fbc26 feat: DKIM-sign bounce messages (mainly "user does not exist")
This was originally based on Jagoda's https://github.com/chatmail/relay/pull/874
but then the postfix config was simplified, and it comes with a simpler and more robust test.
2026-05-12 14:19:11 +02:00
missytake
d054fbb5aa docs: document how to upgrade to new version (#965)
Co-authored-by: Jagoda Estera Ślązak <128227338+j-g00da@users.noreply.github.com>
2026-05-12 14:13:28 +02:00
j4n
def08c52f4 feat(doc/docker): Introduce docker images in documentation 2026-05-12 13:45:21 +02:00
Jagoda Estera Ślązak
32cfa9c76c chore(deps): Upgrade filtermail to v0.6.6 (#967)
## 0.6.6 - 2026-05-12

### Bug Fixes

- Return HTTP 200 because madmail expects it, and make sure https is immediately retried when SMTP fails

### Features

- Improved SMTP error responses

### Miscellaneous Tasks

- Remove mac and windows from matrix tests
- Run cmlxc tests in all classic/classic-ipv4/madmail combinations

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-05-12 13:24:23 +02:00
Jagoda Estera Ślązak
c0b207c320 chore(deps): Upgrade filtermail to v0.6.5 (#966) 2026-05-12 10:37:28 +02:00
holger krekel
4ebde2825d feat: support setup without domain, with only an IPv4 address (#963)
* dovecot: enable login names with square brackets

* config: make IPv4-only relays use self-signed TLS certs

* postfix: make delivery for IP-only relays work

* cmdeploy: skip DNS checks for IPv4 only relays

* www: generate dclogin codes for IPv4-only relays

* opendkim: disable DKIM signing on ipv4-only relays

* get delivery working

* get tests working on IPv4 only machine

* doc: document IPv4-only relays

* dns: warn if mail_domain is an IP, instead of checking DNS

* config: validate domains when formatting them

* ci: add cmlxc testing for no-DNS relays

* ci: run no-dns and normal CI in parallel

* retain "config.mail_domain" as the domain part of @ email addresses, so for ipv4 relays  "[1.2.3.4]" and introduce config.ipv4_relay and config.mail_domain_bare helpers.

* ci: migrate from --no-dns to --type ipv4 for cmlxc compatibility

* cleanup dead code, fix docs, fixate cmlxc version

---------

Co-authored-by: missytake <missytake@systemli.org>
2026-05-11 21:52:33 +02:00
holger krekel
6a7e6ce9e7 feat: expose metadata "maxsmtprecipients" value
also add metadata tests and make metadata lookup method more readable by using structural match/case syntax
2026-05-11 20:08:38 +02:00
holger krekel
8db668c037 fix(logging): log all http requests to syslog 2026-05-10 23:32:42 +02:00
holger krekel
45fafa10a9 fix: legacy token metadata storage used list type, but if no new setmetadata happened, the user would not be notified at all. 2026-05-08 21:39:40 +02:00
missytake
ee435a7ef7 fix(dns): query correct NS if MNAME server is hidden (#954)
replaces #870
fix #851

* fix(dns): address possible IndexError
* fix(dns): remove redundant docstring
* fix(dns): don't make NS explicit if None
* bump cmlxc to 0.13.5 which fixes a powerdns config issue
* remove the unneccessary SOA mocks, simplify mock tests, and run ruff format

Co-authored-by: holger krekel <holger@merlinux.eu>
2026-05-08 19:34:42 +02:00
missytake
8fafd4e79f fix(nginx): properly redirect www to mail_domain 2026-05-07 23:00:02 +02:00
punkero-org
129b8a20bc fix(cmdeploy): stop and disable unbound-resolvconf
Commit 825831e purges resolvconf, however the unbound service
activates a 'wants' unit for async resolvconf updates. This
results in errors in systemd startup as the unit will now always fail.

Stop and disable the unbound-resolvconf unit activation
2026-05-07 13:40:19 +02:00
holger krekel
a1f64ebd96 refactor: introduce automated change-tracking across deployers 2026-05-06 20:02:13 +02:00
j4n
fb64be97b5 fix(mtail): correct boot ordering and deploy restart logic
Correct the systemd unit modifications in 98bc1503 that lead to startup
failures in some instances. Switch to After+Wants = network-online.target
and add RestartSec=2s to give late-binding more interfaces time to appear.

In the deployer, capture the files.template() return value and
appropriately set need_restart and daemon_reload.
2026-05-06 14:04:32 +02:00
Jagoda Estera Ślązak
b05e26819f fix: Increase concurrency limit and re-enable filtermail-transport (#949) 2026-05-05 18:30:20 +02:00
Jagoda Estera Ślązak
1db586b3eb fix(filtermail): Disable filtermail-transport for now (#948)
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-05-05 09:07:06 +02:00
Jagoda Ślązak
44fe2dc08f fix: Use path with no leading slash for mxdeliv
For compatibility with madmail,
we want to use path with no leading
slash. This change saves us from
having to follow redirects.

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-05-01 17:37:35 +02:00
Jagoda Ślązak
8721600d13 build(deps): Upgrade to filtermail v0.6.4
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-05-01 17:37:31 +02:00
Jagoda Ślązak
dfed2b4681 feat: Use filtermail for delivery to remote MTAs
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-05-01 17:37:28 +02:00
holger krekel
f5fd286663 fix: make www tests work with editable instead of just plain installs 2026-05-01 16:52:09 +02:00
49 changed files with 969 additions and 916 deletions

40
.github/workflows/ci-no-dns.yaml vendored Normal file
View File

@@ -0,0 +1,40 @@
name: No-DNS
on:
# Triggers when a PR is merged into main or a direct push occurs
push:
branches: [ "main" ]
# Triggers for any PR (and its subsequent commits) targeting the main branch
pull_request:
branches: [ "main" ]
permissions: {}
# Newest push wins: Prevents multiple runs from clashing and wasting runner efforts
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
no-dns:
name: LXC deploy and test
uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@v0.14.6
with:
cmlxc_version: v0.14.6
cmlxc_commands: |
cmlxc init
# single cmdeploy relay test
cmlxc -v deploy-cmdeploy --source ./repo --type ipv4 cm0
cmlxc -v test-cmdeploy cm0
# cross cmdeploy relay test (two ipv4 relays)
cmlxc -v deploy-cmdeploy --source ./repo --ipv4-only --type ipv4 cm1
cmlxc -v test-cmdeploy cm0 cm1
# cross cmdeploy/madmail relay tests
cmlxc -v deploy-madmail mad0
cmlxc -v test-cmdeploy cm0 mad0
cmlxc -v test-mini mad0 cm0
cmlxc -v test-mini cm0 mad0

View File

@@ -1,4 +1,4 @@
name: Run unit-tests and container-based deploy+test verification name: CI
on: on:
# Triggers when a PR is merged into main or a direct push occurs # Triggers when a PR is merged into main or a direct push occurs
@@ -29,7 +29,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false persist-credentials: false
- name: download filtermail - name: download filtermail
run: curl -L https://github.com/chatmail/filtermail/releases/download/v0.6.1/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.6.6/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
@@ -57,8 +57,9 @@ jobs:
lxc-test: lxc-test:
name: LXC deploy and test name: LXC deploy and test
uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@v0.10.0 uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@v0.14.6
with: with:
cmlxc_version: v0.14.6
cmlxc_commands: | cmlxc_commands: |
cmlxc init cmlxc init
# single cmdeploy relay test # single cmdeploy relay test
@@ -75,3 +76,4 @@ jobs:
cmlxc -v test-cmdeploy cm0 mad0 cmlxc -v test-cmdeploy cm0 mad0
cmlxc -v test-mini cm0 mad0 cmlxc -v test-mini cm0 mad0
cmlxc -v test-mini mad0 cm0 cmlxc -v test-mini mad0 cm0

View File

@@ -10,6 +10,7 @@ dependencies = [
"filelock", "filelock",
"requests", "requests",
"crypt-r >= 3.13.1 ; python_version >= '3.11'", "crypt-r >= 3.13.1 ; python_version >= '3.11'",
"domain-validator",
] ]
[tool.setuptools] [tool.setuptools]

View File

@@ -1,6 +1,8 @@
import ipaddress
from pathlib import Path from pathlib import Path
import iniconfig import iniconfig
from domain_validator import DomainValidator
from chatmaild.user import User from chatmaild.user import User
@@ -8,30 +10,39 @@ 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:
def __init__(self, inipath, params): def __init__(self, inipath, params):
self._inipath = inipath self._inipath = inipath
self.mail_domain = params["mail_domain"] raw_domain = params["mail_domain"]
self.mail_domain_bare = raw_domain
if is_valid_ipv4(raw_domain):
self.ipv4_relay = raw_domain
self.mail_domain = f"[{raw_domain}]"
self.postfix_myhostname = ipaddress.IPv4Address(raw_domain).reverse_pointer
else:
DomainValidator().validate_domain_re(raw_domain)
self.ipv4_relay = None
self.mail_domain = raw_domain
self.postfix_myhostname = raw_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(
@@ -40,6 +51,9 @@ class Config:
self.filtermail_http_port_incoming = int( self.filtermail_http_port_incoming = int(
params.get("filtermail_http_port_incoming", "10082") params.get("filtermail_http_port_incoming", "10082")
) )
self.filtermail_lmtp_port_transport = int(
params.get("filtermail_lmtp_port_transport", "10083")
)
self.postfix_reinject_port = int(params.get("postfix_reinject_port", "10025")) self.postfix_reinject_port = int(params.get("postfix_reinject_port", "10025"))
self.postfix_reinject_port_incoming = int( self.postfix_reinject_port_incoming = int(
params.get("postfix_reinject_port_incoming", "10026") params.get("postfix_reinject_port_incoming", "10026")
@@ -50,7 +64,7 @@ class Config:
self.imap_rawlog = params.get("imap_rawlog", "false").lower() == "true" self.imap_rawlog = params.get("imap_rawlog", "false").lower() == "true"
self.imap_compress = params.get("imap_compress", "false").lower() == "true" self.imap_compress = params.get("imap_compress", "false").lower() == "true"
if "iroh_relay" not in params: if "iroh_relay" not in params:
self.iroh_relay = "https://" + params["mail_domain"] self.iroh_relay = "https://" + raw_domain
self.enable_iroh_relay = True self.enable_iroh_relay = True
else: else:
self.iroh_relay = params["iroh_relay"].strip() self.iroh_relay = params["iroh_relay"].strip()
@@ -76,17 +90,17 @@ class Config:
) )
self.tls_cert_mode = "external" self.tls_cert_mode = "external"
self.tls_cert_path, self.tls_key_path = parts self.tls_cert_path, self.tls_key_path = parts
elif self.mail_domain.startswith("_"): elif raw_domain.startswith("_") or self.ipv4_relay:
self.tls_cert_mode = "self" self.tls_cert_mode = "self"
self.tls_cert_path = "/etc/ssl/certs/mailserver.pem" self.tls_cert_path = "/etc/ssl/certs/mailserver.pem"
self.tls_key_path = "/etc/ssl/private/mailserver.key" self.tls_key_path = "/etc/ssl/private/mailserver.key"
else: else:
self.tls_cert_mode = "acme" self.tls_cert_mode = "acme"
self.tls_cert_path = f"/var/lib/acme/live/{self.mail_domain}/fullchain" self.tls_cert_path = f"/var/lib/acme/live/{raw_domain}/fullchain"
self.tls_key_path = f"/var/lib/acme/live/{self.mail_domain}/privkey" self.tls_key_path = f"/var/lib/acme/live/{raw_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/{raw_domain}")
self.mailboxes_dir = Path(mbdir.strip()) self.mailboxes_dir = Path(mbdir.strip())
# old unused option (except for first migration from sqlite to maildir store) # old unused option (except for first migration from sqlite to maildir store)
@@ -147,28 +161,13 @@ def get_default_config_content(mail_domain, **overrides):
for name, value in extra.items(): for name, value in extra.items():
new_line = f"{name} = {value}" new_line = f"{name} = {value}"
new_lines.append(new_line) new_lines.append(new_line)
return "\n".join(new_lines)
content = "\n".join(new_lines)
# apply testrun privacy overrides def is_valid_ipv4(address: str) -> bool:
"""Check if a mail_domain is an IPv4 address."""
if mail_domain.endswith(".testrun.org"): try:
override_inipath = inidir.joinpath("override-testrun.ini") ipaddress.IPv4Address(address)
privacy = iniconfig.IniConfig(override_inipath)["privacy"] return True
lines = [] except ValueError:
for line in content.split("\n"): return False
for key, value in privacy.items():
value_lines = value.format(mail_domain=mail_domain).strip().split("\n")
if not line.startswith(f"{key} =") or not value_lines:
continue
if len(value_lines) == 1:
lines.append(f"{key} = {value}")
else:
lines.append(f"{key} =")
for vl in value_lines:
lines.append(f" {vl}")
break
else:
lines.append(line)
content = "\n".join(lines)
return content

View File

@@ -12,42 +12,42 @@ 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
# Oldest messages will be removed automatically, so mailboxes never run full. # (Oldest messages will be removed automatically, so mailboxes never run full)
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 =
# Use externally managed TLS certificates instead of built-in acmetool. # Use externally managed TLS certificates instead of built-in acmetool.
# Paths refer to files on the deployment server (not the build machine). # Paths refer to files on the deployment server (not the build machine).
@@ -63,19 +63,11 @@ passthrough_recipients =
# Deployment Details # Deployment Details
# #
# SMTP outgoing filtermail and reinjection
filtermail_smtp_port = 10080
postfix_reinject_port = 10025
# SMTP incoming filtermail and reinjection
filtermail_smtp_port_incoming = 10081
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.
@@ -108,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,16 +0,0 @@
[privacy]
passthrough_recipients = privacy@testrun.org echo@{mail_domain}
privacy_postal =
Merlinux GmbH, Represented by the managing director H. Krekel,
Reichgrafen Str. 20, 79102 Freiburg, Germany
privacy_mail = privacy@testrun.org
privacy_pdo =
Prof. Dr. Fabian Schmieder, lexICT UG (limited), Ostfeldstr. 49, 30559 Hannover.
You can contact him at *delta-privacy@merlinux.eu* (Keyword: DPO)
privacy_supervisor =
State Commissioner for Data Protection and Freedom of Information of
Baden-Württemberg in 70173 Stuttgart, Germany.

View File

@@ -70,6 +70,9 @@ class Metadata:
# Some tokens have expired, remove them. # Some tokens have expired, remove them.
with self._modify_tokens(addr) as _tokens: with self._modify_tokens(addr) as _tokens:
pass pass
elif isinstance(tokens, list):
with self._modify_tokens(addr) as tokens:
token_list = list(tokens.keys())
else: else:
token_list = [] token_list = []
return token_list return token_list
@@ -85,29 +88,27 @@ class MetadataDictProxy(DictProxy):
def handle_lookup(self, parts): def handle_lookup(self, parts):
# Lpriv/43f5f508a7ea0366dff30200c15250e3/devicetoken\tlkj123poi@c2.testrun.org # Lpriv/43f5f508a7ea0366dff30200c15250e3/devicetoken\tlkj123poi@c2.testrun.org
keyparts = parts[0].split("/", 2) match parts[0].split("/", 2):
if keyparts[0] == "priv": case ["priv", _, keyname] if keyname == self.metadata.DEVICETOKEN_KEY:
keyname = keyparts[2] addr = parts[1]
addr = parts[1]
if keyname == self.metadata.DEVICETOKEN_KEY:
res = " ".join(self.metadata.get_tokens_for_addr(addr)) res = " ".join(self.metadata.get_tokens_for_addr(addr))
return f"O{res}\n" return f"O{res}\n"
elif keyparts[0] == "shared": case ["shared", _, keyname]:
keyname = keyparts[2] prefix = "vendor/vendor.dovecot/pvt/server/vendor/deltachat/"
if ( if keyname.startswith(prefix):
keyname == "vendor/vendor.dovecot/pvt/server/vendor/deltachat/irohrelay" match keyname[len(prefix) :]:
and self.iroh_relay case "irohrelay" if self.iroh_relay:
): return f"O{self.iroh_relay}\n"
# Handle `GETMETADATA "" /shared/vendor/deltachat/irohrelay` case "turn":
return f"O{self.iroh_relay}\n" try:
elif keyname == "vendor/vendor.dovecot/pvt/server/vendor/deltachat/turn": res = turn_credentials()
try: except Exception:
res = turn_credentials() logging.exception("failed to get TURN credentials")
except Exception: return "N\n"
logging.exception("failed to get TURN credentials") return f"O{self.turn_hostname}:3478:{res}\n"
return "N\n" case "maxsmtprecipients":
port = 3478 # postfix default (see "postconf smtpd_recipient_limit")
return f"O{self.turn_hostname}:{port}:{res}\n" return "O1000\n"
logging.warning(f"lookup ignored: {parts!r}") logging.warning(f"lookup ignored: {parts!r}")
return "N\n" return "N\n"
@@ -117,12 +118,13 @@ class MetadataDictProxy(DictProxy):
# https://github.com/dovecot/core/blob/main/src/lib-storage/mailbox-attribute.h # https://github.com/dovecot/core/blob/main/src/lib-storage/mailbox-attribute.h
keyname = parts[1].split("/") keyname = parts[1].split("/")
value = parts[2] if len(parts) > 2 else "" value = parts[2] if len(parts) > 2 else ""
if keyname[0] == "priv" and keyname[2] == self.metadata.DEVICETOKEN_KEY: match keyname:
self.metadata.add_token_to_addr(addr, value) case ["priv", _, key] if key == self.metadata.DEVICETOKEN_KEY:
return True self.metadata.add_token_to_addr(addr, value)
elif keyname[0] == "priv" and keyname[2] == "messagenew": return True
self.notifier.new_message_for_addr(addr, self.metadata) case ["priv", _, "messagenew"]:
return True self.notifier.new_message_for_addr(addr, self.metadata)
return True
return False return False

View File

@@ -2,7 +2,6 @@
"""CGI script for creating new accounts.""" """CGI script for creating new accounts."""
import ipaddress
import json import json
import secrets import secrets
import string import string
@@ -15,16 +14,6 @@ ALPHANUMERIC = string.ascii_lowercase + string.digits
ALPHANUMERIC_PUNCT = string.ascii_letters + string.digits + string.punctuation ALPHANUMERIC_PUNCT = string.ascii_letters + string.digits + string.punctuation
def wrap_ip(host):
if host.startswith("[") and host.endswith("]"):
return host
try:
ipaddress.ip_address(host)
return f"[{host}]"
except ValueError:
return host
def create_newemail_dict(config: Config): def create_newemail_dict(config: Config):
user = "".join( user = "".join(
secrets.choice(ALPHANUMERIC) for _ in range(config.username_max_length) secrets.choice(ALPHANUMERIC) for _ in range(config.username_max_length)
@@ -33,16 +22,22 @@ def create_newemail_dict(config: Config):
secrets.choice(ALPHANUMERIC_PUNCT) secrets.choice(ALPHANUMERIC_PUNCT)
for _ in range(config.password_min_length + 3) for _ in range(config.password_min_length + 3)
) )
return dict(email=f"{user}@{wrap_ip(config.mail_domain)}", password=f"{password}") return dict(email=f"{user}@{config.mail_domain}", password=f"{password}")
def create_dclogin_url(email, password): def create_dclogin_url(config, email, password):
"""Build a dclogin: URL with credentials and self-signed cert acceptance. """Build a dclogin: URL with credentials and self-signed cert acceptance.
Uses ic=3 (AcceptInvalidCertificates) so chatmail clients Uses ic=3 (AcceptInvalidCertificates) so chatmail clients
can connect to servers with self-signed TLS certificates. can connect to servers with self-signed TLS certificates.
""" """
return f"dclogin:{quote(email, safe='@')}?p={quote(password, safe='')}&v=1&ic=3" if config.ipv4_relay:
imap_host = "&ih=" + config.ipv4_relay
smtp_host = "&sh=" + config.ipv4_relay
else:
imap_host = ""
smtp_host = ""
return f"dclogin:{quote(email, safe='@[]')}?p={quote(password, safe='')}&v=1{imap_host}{smtp_host}&ic=3"
def print_new_account(): def print_new_account():
@@ -51,7 +46,9 @@ def print_new_account():
result = dict(email=creds["email"], password=creds["password"]) result = dict(email=creds["email"], password=creds["password"])
if config.tls_cert_mode == "self": if config.tls_cert_mode == "self":
result["dclogin_url"] = create_dclogin_url(creds["email"], creds["password"]) result["dclogin_url"] = create_dclogin_url(
config, creds["email"], creds["password"]
)
print("Content-Type: application/json") print("Content-Type: application/json")
print("") print("")

View File

@@ -31,6 +31,11 @@ def example_config(make_config):
return make_config("chat.example.org") return make_config("chat.example.org")
@pytest.fixture
def ipv4_config(make_config):
return make_config("1.3.3.7")
@pytest.fixture @pytest.fixture
def maildomain(example_config): def maildomain(example_config):
return example_config.mail_domain return example_config.mail_domain

View File

@@ -1,6 +1,10 @@
import pytest import pytest
from chatmaild.config import parse_size_mb, read_config from chatmaild.config import (
is_valid_ipv4,
parse_size_mb,
read_config,
)
def test_read_config_basic(example_config): def test_read_config_basic(example_config):
@@ -9,10 +13,21 @@ def test_read_config_basic(example_config):
assert not example_config.privacy_pdo and not example_config.privacy_postal assert not example_config.privacy_pdo and not example_config.privacy_postal
inipath = example_config._inipath inipath = example_config._inipath
inipath.write_text(inipath.read_text().replace("60", "37")) inipath.write_text(
inipath.read_text().replace(
"#max_user_send_per_minute = 60",
"max_user_send_per_minute = 37",
)
)
example_config = read_config(inipath) example_config = read_config(inipath)
assert example_config.max_user_send_per_minute == 37 assert example_config.max_user_send_per_minute == 37
assert example_config.mail_domain == "chat.example.org" assert example_config.mail_domain == "chat.example.org"
assert example_config.ipv4_relay is None
def test_read_config_ipv4(ipv4_config):
assert ipv4_config.ipv4_relay == "1.3.3.7"
assert ipv4_config.mail_domain == "[1.3.3.7]"
def test_read_config_basic_using_defaults(tmp_path, maildomain): def test_read_config_basic_using_defaults(tmp_path, maildomain):
@@ -21,26 +36,17 @@ def test_read_config_basic_using_defaults(tmp_path, maildomain):
example_config = read_config(inipath) example_config = read_config(inipath)
assert example_config.max_user_send_per_minute == 60 assert example_config.max_user_send_per_minute == 60
assert example_config.filtermail_smtp_port_incoming == 10081 assert example_config.filtermail_smtp_port_incoming == 10081
assert example_config.filtermail_smtp_port == 10080
assert example_config.postfix_reinject_port == 10025
def test_read_config_testrun(make_config): assert example_config.max_user_send_per_minute == 60
config = make_config("something.testrun.org") assert example_config.max_mailbox_size == "500M"
assert config.mail_domain == "something.testrun.org" assert example_config.delete_mails_after == "20"
assert len(config.privacy_postal.split("\n")) > 1 assert example_config.delete_large_after == "7"
assert len(config.privacy_supervisor.split("\n")) > 1 assert example_config.username_min_length == 9
assert len(config.privacy_pdo.split("\n")) > 1 assert example_config.username_max_length == 9
assert config.privacy_mail == "privacy@testrun.org" assert example_config.password_min_length == 9
assert config.filtermail_smtp_port == 10080 assert example_config.passthrough_recipients == []
assert config.postfix_reinject_port == 10025 assert example_config.passthrough_senders == []
assert config.max_user_send_per_minute == 60
assert config.max_mailbox_size == "500M"
assert config.delete_mails_after == "20"
assert config.delete_large_after == "7"
assert config.username_min_length == 9
assert config.username_max_length == 9
assert config.password_min_length == 9
assert "privacy@testrun.org" in config.passthrough_recipients
assert config.passthrough_senders == []
def test_config_userstate_paths(make_config, tmp_path): def test_config_userstate_paths(make_config, tmp_path):
@@ -135,3 +141,17 @@ def test_max_mailbox_size_mb(make_config):
config = make_config("chat.example.org") config = make_config("chat.example.org")
assert config.max_mailbox_size == "500M" assert config.max_mailbox_size == "500M"
assert config.max_mailbox_size_mb == 500 assert config.max_mailbox_size_mb == 500
@pytest.mark.parametrize(
["input", "result"],
[
("example.org", False),
("1.3.3.7", True),
("fe::1", False),
("ad.1e.dag.adf", False),
("12394142", False),
],
)
def test_is_valid_ipv4(input, result):
assert result == is_valid_ipv4(input)

View File

@@ -360,15 +360,39 @@ def test_turn_credentials_success(notifier, metadata, monkeypatch):
def test_iroh_relay(dictproxy): def test_iroh_relay(dictproxy):
rfile = io.BytesIO( key = b"Lshared/0123/vendor/vendor.dovecot/pvt/server/vendor/deltachat/irohrelay\tuser@example.org"
b"\n".join( rfile, wfile = io.BytesIO(b"H\n" + key), io.BytesIO()
[
b"H",
b"Lshared/0123/vendor/vendor.dovecot/pvt/server/vendor/deltachat/irohrelay\tuser@example.org",
]
)
)
wfile = io.BytesIO()
dictproxy.iroh_relay = "https://example.org/" dictproxy.iroh_relay = "https://example.org/"
dictproxy.loop_forever(rfile, wfile) dictproxy.loop_forever(rfile, wfile)
assert wfile.getvalue() == b"Ohttps://example.org/\n" assert wfile.getvalue() == b"Ohttps://example.org/\n"
def test_legacy_token_migration(metadata, testaddr):
with metadata.get_metadata_dict(testaddr).modify() as data:
data[metadata.DEVICETOKEN_KEY] = ["oldtoken1", "oldtoken2"]
assert metadata.get_tokens_for_addr(testaddr) == ["oldtoken1", "oldtoken2"]
mdict = metadata.get_metadata_dict(testaddr).read()
tokens = mdict[metadata.DEVICETOKEN_KEY]
assert isinstance(tokens, dict)
assert "oldtoken1" in tokens and "oldtoken2" in tokens
@pytest.mark.parametrize(
"suffix, expected",
[
(b"vendor/deltachat/maxsmtprecipients", b"O1000\n"),
(b"wrong/prefix/key", b"N\n"),
(b"vendor/deltachat/unknown", b"N\n"),
],
ids=["maxsmtprecipients", "prefix_mismatch", "unknown_name"],
)
def test_shared_lookup(dictproxy, suffix, expected):
key = (
b"Lshared/0123/vendor/vendor.dovecot/pvt/server/"
+ suffix
+ b"\tuser@example.org"
)
rfile, wfile = io.BytesIO(b"H\n" + key), io.BytesIO()
dictproxy.loop_forever(rfile, wfile)
assert wfile.getvalue() == expected

View File

@@ -48,6 +48,8 @@ def test_migration(tmp_path, example_config, caplog):
assert passdb_path.stat().st_size > 10000 assert passdb_path.stat().st_size > 10000
example_config.passdb_path = passdb_path example_config.passdb_path = passdb_path
# ensure logging.info records are captured regardless of global configuration
caplog.set_level("INFO")
assert not caplog.records assert not caplog.records

View File

@@ -19,24 +19,35 @@ def test_create_newemail_dict(example_config):
assert ac1["password"] != ac2["password"] assert ac1["password"] != ac2["password"]
def test_create_newemail_dict_ip(make_config): def test_create_newemail_dict_ip(ipv4_config):
config = make_config("1.2.3.4") ac = create_newemail_dict(ipv4_config)
ac = create_newemail_dict(config) assert ac["email"].endswith("@[1.3.3.7]")
assert ac["email"].endswith("@[1.2.3.4]")
def test_create_dclogin_url(): def test_create_dclogin_url(example_config):
url = create_dclogin_url("user@example.org", "p@ss w+rd") addr = "user@example.org"
password = "p@ss w+rd"
url = create_dclogin_url(example_config, addr, password)
assert url.startswith("dclogin:") assert url.startswith("dclogin:")
assert "v=1" in url assert "v=1" in url
assert "ic=3" in url assert "ic=3" in url
assert "user@example.org" in url assert addr in url
# password special chars must be encoded # password special chars must be encoded
assert "p%40ss" in url assert "p%40ss" in url
assert "w%2Brd" in url assert "w%2Brd" in url
def test_create_dclogin_url_ipv4(ipv4_config):
addr = "user@[1.3.3.7]"
password = "p@ss w+rd"
url = create_dclogin_url(ipv4_config, addr, password)
assert url.startswith("dclogin:")
assert "v=1" in url
assert "ic=3" in url
assert addr 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()

View File

@@ -1,6 +1,4 @@
import importlib.resources from pyinfra.operations import apt, server
from pyinfra.operations import apt, files, server, systemd
from ..basedeploy import Deployer from ..basedeploy import Deployer
@@ -9,9 +7,6 @@ class AcmetoolDeployer(Deployer):
def __init__(self, email, domains): def __init__(self, email, domains):
self.domains = domains self.domains = domains
self.email = email self.email = email
self.need_restart_redirector = False
self.need_restart_reconcile_service = False
self.need_restart_reconcile_timer = False
def install(self): def install(self):
apt.packages( apt.packages(
@@ -19,121 +14,41 @@ class AcmetoolDeployer(Deployer):
packages=["acmetool"], packages=["acmetool"],
) )
files.file( self.remove_file("/etc/cron.d/acmetool")
name="Remove old acmetool cronjob, it is replaced with systemd timer.",
path="/etc/cron.d/acmetool",
present=False,
)
files.put( self.put_executable("acmetool/acmetool.hook", "/etc/acme/hooks/nginx")
name="Install acmetool hook.", self.remove_file("/usr/lib/acme/hooks/nginx")
src=importlib.resources.files(__package__)
.joinpath("acmetool.hook")
.open("rb"),
dest="/etc/acme/hooks/nginx",
user="root",
group="root",
mode="755",
)
files.file(
name="Remove acmetool hook from the wrong location where it was previously installed.",
path="/usr/lib/acme/hooks/nginx",
present=False,
)
def configure(self): def configure(self):
files.template( self.put_template(
src=importlib.resources.files(__package__).joinpath( "acmetool/response-file.yaml.j2",
"response-file.yaml.j2" "/var/lib/acme/conf/responses",
),
dest="/var/lib/acme/conf/responses",
user="root",
group="root",
mode="644",
email=self.email, email=self.email,
) )
files.template( self.put_template(
src=importlib.resources.files(__package__).joinpath("target.yaml.j2"), "acmetool/target.yaml.j2",
dest="/var/lib/acme/conf/target", "/var/lib/acme/conf/target",
user="root",
group="root",
mode="644",
) )
server.shell( server.shell(
name=f"Remove old acmetool desired files for {self.domains[0]}", name=f"Remove old acmetool desired files for {self.domains[0]}",
commands=[f"rm -f /var/lib/acme/desired/{self.domains[0]}-*"], commands=[f"rm -f /var/lib/acme/desired/{self.domains[0]}-*"],
) )
files.template( self.put_template(
src=importlib.resources.files(__package__).joinpath("desired.yaml.j2"), "acmetool/desired.yaml.j2",
dest=f"/var/lib/acme/desired/{self.domains[0]}", # 0 is mailhost TLD f"/var/lib/acme/desired/{self.domains[0]}",
user="root",
group="root",
mode="644",
domains=self.domains, domains=self.domains,
) )
service_file = files.put( self.ensure_systemd_unit("acmetool/acmetool-redirector.service")
src=importlib.resources.files(__package__).joinpath( self.ensure_systemd_unit("acmetool/acmetool-reconcile.service")
"acmetool-redirector.service" self.ensure_systemd_unit("acmetool/acmetool-reconcile.timer")
),
dest="/etc/systemd/system/acmetool-redirector.service",
user="root",
group="root",
mode="644",
)
self.need_restart_redirector = service_file.changed
reconcile_service_file = files.put(
src=importlib.resources.files(__package__).joinpath(
"acmetool-reconcile.service"
),
dest="/etc/systemd/system/acmetool-reconcile.service",
user="root",
group="root",
mode="644",
)
self.need_restart_reconcile_service = reconcile_service_file.changed
reconcile_timer_file = files.put(
src=importlib.resources.files(__package__).joinpath(
"acmetool-reconcile.timer"
),
dest="/etc/systemd/system/acmetool-reconcile.timer",
user="root",
group="root",
mode="644",
)
self.need_restart_reconcile_timer = reconcile_timer_file.changed
def activate(self): def activate(self):
systemd.service( self.ensure_service("acmetool-redirector.service")
name="Setup acmetool-redirector service", self.ensure_service("acmetool-reconcile.service", running=False, enabled=False)
service="acmetool-redirector.service", self.ensure_service("acmetool-reconcile.timer")
running=True,
enabled=True,
restarted=self.need_restart_redirector,
)
self.need_restart_redirector = False
systemd.service(
name="Setup acmetool-reconcile service",
service="acmetool-reconcile.service",
running=False,
enabled=False,
daemon_reload=self.need_restart_reconcile_service,
)
self.need_restart_reconcile_service = False
systemd.service(
name="Setup acmetool-reconcile timer",
service="acmetool-reconcile.timer",
running=True,
enabled=True,
daemon_reload=self.need_restart_reconcile_timer,
)
self.need_restart_reconcile_timer = False
server.shell( server.shell(
name=f"Reconcile certificates for: {', '.join(self.domains)}", name=f"Reconcile certificates for: {', '.join(self.domains)}",

View File

@@ -4,6 +4,7 @@ import os
from contextlib import contextmanager from contextlib import contextmanager
from pyinfra import host from pyinfra import host
from pyinfra.facts.files import Sha256File
from pyinfra.facts.server import Command from pyinfra.facts.server import Command
from pyinfra.operations import files, server, systemd from pyinfra.operations import files, server, systemd
@@ -50,11 +51,10 @@ def get_resource(arg, pkg=__package__):
return importlib.resources.files(pkg).joinpath(arg) return importlib.resources.files(pkg).joinpath(arg)
def configure_remote_units(mail_domain, units) -> None: def configure_remote_units(deployer, mail_domain, units) -> None:
remote_base_dir = "/usr/local/lib/chatmaild" remote_base_dir = "/usr/local/lib/chatmaild"
remote_venv_dir = f"{remote_base_dir}/venv" remote_venv_dir = f"{remote_base_dir}/venv"
remote_chatmail_inipath = f"{remote_base_dir}/chatmail.ini" remote_chatmail_inipath = f"{remote_base_dir}/chatmail.ini"
root_owned = dict(user="root", group="root", mode="644")
# install systemd units # install systemd units
for fn in units: for fn in units:
@@ -70,15 +70,13 @@ def configure_remote_units(mail_domain, units) -> None:
source_path = get_resource(f"service/{basename}.f") source_path = get_resource(f"service/{basename}.f")
content = source_path.read_text().format(**params).encode() content = source_path.read_text().format(**params).encode()
files.put( deployer.put_file(
name=f"Upload {basename}",
src=io.BytesIO(content), src=io.BytesIO(content),
dest=f"/etc/systemd/system/{basename}", dest=f"/etc/systemd/system/{basename}",
**root_owned,
) )
def activate_remote_units(units) -> None: def activate_remote_units(deployer, units) -> None:
# activate systemd units # activate systemd units
for fn in units: for fn in units:
basename = fn if "." in fn else f"{fn}.service" basename = fn if "." in fn else f"{fn}.service"
@@ -88,14 +86,8 @@ def activate_remote_units(units) -> None:
enabled = False enabled = False
else: else:
enabled = True enabled = True
systemd.service(
name=f"Setup {basename}", deployer.ensure_service(basename, running=enabled, enabled=enabled)
service=basename,
running=enabled,
enabled=enabled,
restarted=enabled,
daemon_reload=True,
)
class Deployment: class Deployment:
@@ -141,6 +133,7 @@ class Deployment:
class Deployer: class Deployer:
need_restart = False need_restart = False
daemon_reload = False
def install(self): def install(self):
pass pass
@@ -150,3 +143,113 @@ class Deployer:
def activate(self): def activate(self):
pass pass
def ensure_service(self, service, running=True, enabled=True):
if running:
verb = "Start and enable"
else:
verb = "Stop"
systemd.service(
name=f"{verb} {service}",
service=service,
running=running,
enabled=enabled,
restarted=self.need_restart if running else False,
daemon_reload=self.daemon_reload,
)
self.daemon_reload = False
def ensure_systemd_unit(self, src, **kwargs):
dest_name = src.split("/")[-1].replace(".j2", "")
dest = f"/etc/systemd/system/{dest_name}"
if src.endswith(".j2"):
return self.put_template(src, dest, **kwargs)
return self.put_file(src, dest)
def put_file(self, src, dest, mode="644"):
if isinstance(src, str):
src = get_resource(src)
res = files.put(
name=f"Upload {dest}",
src=src,
dest=dest,
user="root",
group="root",
mode=mode,
)
return self._update_restart_signals(dest, res)
def put_executable(self, src, dest):
return self.put_file(src, dest, mode="755")
def put_template(self, src, dest, owner="root", **kwargs):
if isinstance(src, str):
src = get_resource(src)
res = files.template(
name=f"Upload {dest}",
src=src,
dest=dest,
user=owner,
group=owner,
mode="644",
**kwargs,
)
return self._update_restart_signals(dest, res)
def remove_file(self, dest):
res = files.file(name=f"Remove {dest}", path=dest, present=False)
return self._update_restart_signals(dest, res)
def ensure_line(self, path, line, **kwargs):
name = kwargs.pop("name", f"Ensure line in {path}")
res = files.line(name=name, path=path, line=line, **kwargs)
return self._update_restart_signals(path, res)
def ensure_directory(self, path, owner="root", mode="755", **kwargs):
name = kwargs.pop("name", f"Ensure directory {path}")
res = files.directory(
name=name,
path=path,
user=owner,
group=owner,
mode=mode,
present=True,
**kwargs,
)
return self._update_restart_signals(path, res)
def remove_directory(self, path, **kwargs):
name = kwargs.pop("name", f"Remove directory {path}")
res = files.directory(name=name, path=path, present=False, **kwargs)
return self._update_restart_signals(path, res)
def download_executable(self, url, dest, sha256sum, extract=None):
existing = host.get_fact(Sha256File, dest)
if existing == sha256sum:
return
tmp = f"{dest}.new"
if extract:
dl_cmd = f"curl -fSL {url} | {extract} >{tmp}"
else:
dl_cmd = f"curl -fSL {url} -o {tmp}"
server.shell(
name=f"Download {dest}",
commands=[
f"({dl_cmd}"
f" && echo '{sha256sum} {tmp}' | sha256sum -c"
f" && mv {tmp} {dest})",
f"chmod 755 {dest}",
],
)
self.need_restart = True
def _update_restart_signals(self, path, res):
if res.changed:
self.need_restart = True
if str(path).startswith("/etc/systemd/system/"):
self.daemon_reload = True
return res

View File

@@ -87,10 +87,12 @@ def run_cmd_options(parser):
def run_cmd(args, out): def run_cmd(args, out):
"""Deploy chatmail services on the remote server.""" """Deploy chatmail services on the remote server."""
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_bare
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" strict_tls = args.config.tls_cert_mode == "acme"
if args.config.ipv4_relay:
args.dns_check_disabled = True
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, strict_tls=strict_tls, print=out.red): if not dns.check_initial_remote_data(remote_data, strict_tls=strict_tls, print=out.red):
@@ -119,6 +121,8 @@ def run_cmd(args, out):
elif not args.dns_check_disabled and strict_tls and 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")
elif args.config.ipv4_relay:
out.green("Deploy completed.")
else: else:
out.green("Deploy completed, call `cmdeploy dns` next.") out.green("Deploy completed, call `cmdeploy dns` next.")
return 0 return 0
@@ -140,6 +144,10 @@ def dns_cmd_options(parser):
def dns_cmd(args, out): def dns_cmd(args, out):
"""Check DNS entries and optionally generate dns zone file.""" """Check DNS entries and optionally generate dns zone file."""
if args.config.ipv4_relay:
ipv4 = args.config.ipv4_relay
print(f"[WARNING] {ipv4} is not a domain, skipping DNS checks.")
return 0
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 tls_cert_mode = args.config.tls_cert_mode
@@ -177,7 +185,7 @@ def status_cmd_options(parser):
def status_cmd(args, out): def status_cmd(args, out):
"""Display status for online chatmail instance.""" """Display status for online chatmail instance."""
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_bare
sshexec = get_sshexec(ssh_host, verbose=args.verbose) sshexec = get_sshexec(ssh_host, verbose=args.verbose)
out.green(f"chatmail domain: {args.config.mail_domain}") out.green(f"chatmail domain: {args.config.mail_domain}")

View File

@@ -12,7 +12,6 @@ from chatmaild.config import read_config
from pyinfra import facts, host, logger from pyinfra import facts, host, logger
from pyinfra.api import FactBase from pyinfra.api import FactBase
from pyinfra.facts import hardware from pyinfra.facts import hardware
from pyinfra.facts.files import Sha256File
from pyinfra.facts.systemd import SystemdEnabled from pyinfra.facts.systemd import SystemdEnabled
from pyinfra.operations import apt, files, pip, server, systemd from pyinfra.operations import apt, files, pip, server, systemd
@@ -25,7 +24,6 @@ from .basedeploy import (
activate_remote_units, activate_remote_units,
blocked_service_startup, blocked_service_startup,
configure_remote_units, configure_remote_units,
get_resource,
has_systemd, has_systemd,
is_in_container, is_in_container,
) )
@@ -82,25 +80,22 @@ def remove_legacy_artifacts():
) )
def _install_remote_venv_with_chatmaild() -> None: def _install_remote_venv_with_chatmaild(deployer) -> None:
remove_legacy_artifacts() remove_legacy_artifacts()
dist_file = _build_chatmaild(dist_dir=Path("chatmaild/dist")) dist_file = _build_chatmaild(dist_dir=Path("chatmaild/dist"))
remote_base_dir = "/usr/local/lib/chatmaild" remote_base_dir = "/usr/local/lib/chatmaild"
remote_dist_file = f"{remote_base_dir}/dist/{dist_file.name}" remote_dist_file = f"{remote_base_dir}/dist/{dist_file.name}"
remote_venv_dir = f"{remote_base_dir}/venv" remote_venv_dir = f"{remote_base_dir}/venv"
root_owned = dict(user="root", group="root", mode="644")
apt.packages( apt.packages(
name="apt install python3-virtualenv", name="apt install python3-virtualenv",
packages=["python3-virtualenv"], packages=["python3-virtualenv"],
) )
files.put( deployer.ensure_directory(f"{remote_base_dir}/dist")
name="Upload chatmaild source package", deployer.put_file(
src=dist_file.open("rb"), src=dist_file.open("rb"),
dest=remote_dist_file, dest=remote_dist_file,
create_remote_dir=True,
**root_owned,
) )
pip.virtualenv( pip.virtualenv(
@@ -122,32 +117,22 @@ def _install_remote_venv_with_chatmaild() -> None:
) )
def _configure_remote_venv_with_chatmaild(config) -> None: def _configure_remote_venv_with_chatmaild(deployer, config) -> None:
remote_base_dir = "/usr/local/lib/chatmaild" remote_base_dir = "/usr/local/lib/chatmaild"
remote_chatmail_inipath = f"{remote_base_dir}/chatmail.ini" remote_chatmail_inipath = f"{remote_base_dir}/chatmail.ini"
root_owned = dict(user="root", group="root", mode="644")
files.put( deployer.put_file(
name=f"Upload {remote_chatmail_inipath}",
src=config._getbytefile(), src=config._getbytefile(),
dest=remote_chatmail_inipath, dest=remote_chatmail_inipath,
**root_owned,
) )
files.file( deployer.remove_file("/etc/cron.d/chatmail-metrics")
path="/etc/cron.d/chatmail-metrics", deployer.remove_file("/var/www/html/metrics")
present=False,
)
files.file(
path="/var/www/html/metrics",
present=False,
)
class UnboundDeployer(Deployer): class UnboundDeployer(Deployer):
def __init__(self, config): def __init__(self, config):
self.config = config self.config = config
self.need_restart = False
def install(self): def install(self):
# On an IPv4-only system, if unbound is started but not configured, # On an IPv4-only system, if unbound is started but not configured,
@@ -176,13 +161,9 @@ class UnboundDeployer(Deployer):
) )
# Configure unbound resolver with Quad9 fallback and a trailing newline # Configure unbound resolver with Quad9 fallback and a trailing newline
# (SolusVM bug). # (SolusVM bug).
files.put( self.put_file(
name="Write static resolv.conf",
src=BytesIO(b"nameserver 127.0.0.1\nnameserver 9.9.9.9\n"), src=BytesIO(b"nameserver 127.0.0.1\nnameserver 9.9.9.9\n"),
dest="/etc/resolv.conf", dest="/etc/resolv.conf",
user="root",
group="root",
mode="644",
) )
server.shell( server.shell(
name="Generate root keys for validating DNSSEC", name="Generate root keys for validating DNSSEC",
@@ -191,26 +172,15 @@ class UnboundDeployer(Deployer):
], ],
) )
if self.config.disable_ipv6: if self.config.disable_ipv6:
files.directory( self.ensure_directory(
path="/etc/unbound/unbound.conf.d", path="/etc/unbound/unbound.conf.d",
present=True,
user="root",
group="root",
mode="755",
) )
conf = files.put( self.put_template(
src=get_resource("unbound/unbound.conf.j2"), "unbound/unbound.conf.j2",
dest="/etc/unbound/unbound.conf.d/chatmail.conf", "/etc/unbound/unbound.conf.d/chatmail.conf",
user="root",
group="root",
mode="644",
) )
else: else:
conf = files.file( self.remove_file("/etc/unbound/unbound.conf.d/chatmail.conf")
path="/etc/unbound/unbound.conf.d/chatmail.conf",
present=False,
)
self.need_restart |= conf.changed
def activate(self): def activate(self):
server.shell( server.shell(
@@ -220,27 +190,25 @@ class UnboundDeployer(Deployer):
], ],
) )
systemd.service( self.ensure_service("unbound.service")
name="Start and enable unbound",
service="unbound.service", self.ensure_service(
running=True, "unbound-resolvconf.service",
enabled=True, running=False,
restarted=self.need_restart, enabled=False,
) )
class MtastsDeployer(Deployer): class MtastsDeployer(Deployer):
def configure(self): def configure(self):
# Remove configuration. # Remove configuration.
files.file("/etc/mta-sts-daemon.yml", present=False) self.remove_file("/etc/mta-sts-daemon.yml")
files.directory("/usr/local/lib/postfix-mta-sts-resolver", present=False) self.remove_directory("/usr/local/lib/postfix-mta-sts-resolver")
files.file("/etc/systemd/system/mta-sts-daemon.service", present=False) self.remove_file("/etc/systemd/system/mta-sts-daemon.service")
def activate(self): def activate(self):
systemd.service( self.ensure_service(
name="Stop MTA-STS daemon", "mta-sts-daemon.service",
service="mta-sts-daemon.service",
daemon_reload=True,
running=False, running=False,
enabled=False, enabled=False,
) )
@@ -251,14 +219,7 @@ class WebsiteDeployer(Deployer):
self.config = config self.config = config
def install(self): def install(self):
files.directory( self.ensure_directory("/var/www")
name="Ensure /var/www exists",
path="/var/www",
user="root",
group="root",
mode="755",
present=True,
)
def configure(self): def configure(self):
www_path, src_dir, build_dir = get_paths(self.config) www_path, src_dir, build_dir = get_paths(self.config)
@@ -288,15 +249,11 @@ class LegacyRemoveDeployer(Deployer):
# remove historic expunge script # remove historic expunge script
# which is now implemented through a systemd timer (chatmail-expire) # which is now implemented through a systemd timer (chatmail-expire)
files.file( self.remove_file("/etc/cron.d/expunge")
path="/etc/cron.d/expunge",
present=False,
)
# Remove OBS repository key that is no longer used. # Remove OBS repository key that is no longer used.
files.file("/etc/apt/keyrings/obs-home-deltachat.gpg", present=False) self.remove_file("/etc/apt/keyrings/obs-home-deltachat.gpg")
files.line( self.ensure_line(
name="Remove DeltaChat OBS home repository from sources.list",
path="/etc/apt/sources.list", path="/etc/apt/sources.list",
line="deb [signed-by=/etc/apt/keyrings/obs-home-deltachat.gpg] https://download.opensuse.org/repositories/home:/deltachat/Debian_12/ ./", line="deb [signed-by=/etc/apt/keyrings/obs-home-deltachat.gpg] https://download.opensuse.org/repositories/home:/deltachat/Debian_12/ ./",
escape_regex_characters=True, escape_regex_characters=True,
@@ -304,11 +261,7 @@ class LegacyRemoveDeployer(Deployer):
) )
# prior relay versions used filelogging # prior relay versions used filelogging
files.directory( self.remove_directory("/var/log/journal/")
name="Ensure old logs on disk are deleted",
path="/var/log/journal/",
present=False,
)
# remove echobot if it is still running # remove echobot if it is still running
if has_systemd() and host.get_fact(SystemdEnabled).get("echobot.service"): if has_systemd() and host.get_fact(SystemdEnabled).get("echobot.service"):
systemd.service( systemd.service(
@@ -350,22 +303,13 @@ class TurnDeployer(Deployer):
"0fb3e792419494e21ecad536464929dba706bb2c88884ed8f1788141d26fc756", "0fb3e792419494e21ecad536464929dba706bb2c88884ed8f1788141d26fc756",
), ),
}[host.get_fact(facts.server.Arch)] }[host.get_fact(facts.server.Arch)]
self.download_executable(url, "/usr/local/bin/chatmail-turn", sha256sum)
existing_sha256sum = host.get_fact(Sha256File, "/usr/local/bin/chatmail-turn")
if existing_sha256sum != sha256sum:
server.shell(
name="Download chatmail-turn",
commands=[
f"(curl -L {url} >/usr/local/bin/chatmail-turn.new && (echo '{sha256sum} /usr/local/bin/chatmail-turn.new' | sha256sum -c) && mv /usr/local/bin/chatmail-turn.new /usr/local/bin/chatmail-turn)",
"chmod 755 /usr/local/bin/chatmail-turn",
],
)
def configure(self): def configure(self):
configure_remote_units(self.mail_domain, self.units) configure_remote_units(self, self.mail_domain, self.units)
def activate(self): def activate(self):
activate_remote_units(self.units) activate_remote_units(self, self.units)
class IrohDeployer(Deployer): class IrohDeployer(Deployer):
@@ -383,72 +327,30 @@ class IrohDeployer(Deployer):
"f8ef27631fac213b3ef668d02acd5b3e215292746a3fc71d90c63115446008b1", "f8ef27631fac213b3ef668d02acd5b3e215292746a3fc71d90c63115446008b1",
), ),
}[host.get_fact(facts.server.Arch)] }[host.get_fact(facts.server.Arch)]
self.download_executable(
existing_sha256sum = host.get_fact(Sha256File, "/usr/local/bin/iroh-relay") url,
if existing_sha256sum != sha256sum: "/usr/local/bin/iroh-relay",
server.shell( sha256sum,
name="Download iroh-relay", extract="gunzip | tar -xf - ./iroh-relay -O",
commands=[ )
f"(curl -L {url} | gunzip | tar -x -f - ./iroh-relay -O >/usr/local/bin/iroh-relay.new && (echo '{sha256sum} /usr/local/bin/iroh-relay.new' | sha256sum -c) && mv /usr/local/bin/iroh-relay.new /usr/local/bin/iroh-relay)",
"chmod 755 /usr/local/bin/iroh-relay",
],
)
self.need_restart = True
def configure(self): def configure(self):
systemd_unit = files.put( self.ensure_systemd_unit("iroh-relay.service")
name="Upload iroh-relay systemd unit", self.put_file("iroh-relay.toml", "/etc/iroh-relay.toml")
src=get_resource("iroh-relay.service"),
dest="/etc/systemd/system/iroh-relay.service",
user="root",
group="root",
mode="644",
)
self.need_restart |= systemd_unit.changed
iroh_config = files.put(
name="Upload iroh-relay config",
src=get_resource("iroh-relay.toml"),
dest="/etc/iroh-relay.toml",
user="root",
group="root",
mode="644",
)
self.need_restart |= iroh_config.changed
def activate(self): def activate(self):
systemd.service( self.ensure_service(
name="Start and enable iroh-relay", "iroh-relay.service",
service="iroh-relay.service",
running=True,
enabled=self.enable_iroh_relay, enabled=self.enable_iroh_relay,
restarted=self.need_restart,
) )
self.need_restart = False
class JournaldDeployer(Deployer): class JournaldDeployer(Deployer):
def configure(self): def configure(self):
journald_conf = files.put( self.put_file("journald.conf", "/etc/systemd/journald.conf")
name="Configure journald",
src=get_resource("journald.conf"),
dest="/etc/systemd/journald.conf",
user="root",
group="root",
mode="644",
)
self.need_restart = journald_conf.changed
def activate(self): def activate(self):
systemd.service( self.ensure_service("systemd-journald.service")
name="Start and enable journald",
service="systemd-journald.service",
running=True,
enabled=True,
restarted=self.need_restart,
)
self.need_restart = False
class ChatmailVenvDeployer(Deployer): class ChatmailVenvDeployer(Deployer):
@@ -464,14 +366,14 @@ class ChatmailVenvDeployer(Deployer):
) )
def install(self): def install(self):
_install_remote_venv_with_chatmaild() _install_remote_venv_with_chatmaild(self)
def configure(self): def configure(self):
_configure_remote_venv_with_chatmaild(self.config) _configure_remote_venv_with_chatmaild(self, self.config)
configure_remote_units(self.config.mail_domain, self.units) configure_remote_units(self, self.config.mail_domain_bare, self.units)
def activate(self): def activate(self):
activate_remote_units(self.units) activate_remote_units(self, self.units)
class ChatmailDeployer(Deployer): class ChatmailDeployer(Deployer):
@@ -485,13 +387,9 @@ class ChatmailDeployer(Deployer):
self.mail_domain = config.mail_domain self.mail_domain = config.mail_domain
def install(self): def install(self):
files.put( self.put_file(
name="Disable installing recommended packages globally",
src=BytesIO(b'APT::Install-Recommends "false";\n'), src=BytesIO(b'APT::Install-Recommends "false";\n'),
dest="/etc/apt/apt.conf.d/00InstallRecommends", dest="/etc/apt/apt.conf.d/00InstallRecommends",
user="root",
group="root",
mode="644",
) )
apt.update(name="apt update", cache_time=24 * 3600) apt.update(name="apt update", cache_time=24 * 3600)
apt.upgrade(name="upgrade apt packages", auto_remove=True) apt.upgrade(name="upgrade apt packages", auto_remove=True)
@@ -508,13 +406,10 @@ class ChatmailDeployer(Deployer):
def configure(self): def configure(self):
# metadata crashes if the mailboxes dir does not exist # metadata crashes if the mailboxes dir does not exist
files.directory( self.ensure_directory(
name="Ensure vmail mailbox directory exists", str(self.config.mailboxes_dir),
path=str(self.config.mailboxes_dir), owner="vmail",
user="vmail",
group="vmail",
mode="700", mode="700",
present=True,
) )
# This file is used by auth proxy. # This file is used by auth proxy.
@@ -535,12 +430,7 @@ class FcgiwrapDeployer(Deployer):
) )
def activate(self): def activate(self):
systemd.service( self.ensure_service("fcgiwrap.service")
name="Start and enable fcgiwrap",
service="fcgiwrap.service",
running=True,
enabled=True,
)
class GithashDeployer(Deployer): class GithashDeployer(Deployer):
@@ -553,12 +443,7 @@ class GithashDeployer(Deployer):
git_diff = subprocess.check_output(["git", "diff"]).decode() git_diff = subprocess.check_output(["git", "diff"]).decode()
except Exception: except Exception:
git_diff = "" git_diff = ""
files.put( self.put_file(src=StringIO(git_hash + git_diff), dest="/etc/chatmail-version")
name="Upload chatmail relay git commit hash",
src=StringIO(git_hash + git_diff),
dest="/etc/chatmail-version",
mode="700",
)
def get_tls_deployer(config, mail_domain): def get_tls_deployer(config, mail_domain):
@@ -584,18 +469,24 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
""" """
config = read_config(config_path) config = read_config(config_path)
check_config(config) check_config(config)
mail_domain = config.mail_domain bare_host = config.mail_domain_bare
if website_only: if website_only:
Deployment().perform_stages([WebsiteDeployer(config)]) Deployment().perform_stages([WebsiteDeployer(config)])
return return
# Check if mtail_address interface is available (if configured) # 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'): if config.mtail_address and config.mtail_address not in (
"127.0.0.1",
"::1",
"localhost",
):
ipv4_addrs = host.get_fact(hardware.Ipv4Addrs) ipv4_addrs = host.get_fact(hardware.Ipv4Addrs)
all_addresses = [addr for addrs in ipv4_addrs.values() for addr in addrs] all_addresses = [addr for addrs in ipv4_addrs.values() for addr in addrs]
if config.mtail_address not in all_addresses: if config.mtail_address not in all_addresses:
Out().red(f"Deploy failed: mtail_address {config.mtail_address} is not available (VPN up?).\n") Out().red(
f"Deploy failed: mtail_address {config.mtail_address} is not available (VPN up?).\n"
)
exit(1) exit(1)
if not is_in_container(): if not is_in_container():
@@ -635,7 +526,7 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
) )
exit(1) exit(1)
tls_deployer = get_tls_deployer(config, mail_domain) tls_deployer = get_tls_deployer(config, bare_host)
all_deployers = [ all_deployers = [
ChatmailDeployer(config), ChatmailDeployer(config),
@@ -643,13 +534,13 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
FiltermailDeployer(), FiltermailDeployer(),
JournaldDeployer(), JournaldDeployer(),
UnboundDeployer(config), UnboundDeployer(config),
TurnDeployer(mail_domain), TurnDeployer(bare_host),
IrohDeployer(config.enable_iroh_relay), IrohDeployer(config.enable_iroh_relay),
tls_deployer, tls_deployer,
WebsiteDeployer(config), WebsiteDeployer(config),
ChatmailVenvDeployer(config), ChatmailVenvDeployer(config),
MtastsDeployer(), MtastsDeployer(),
OpendkimDeployer(mail_domain), *([] if config.ipv4_relay else [OpendkimDeployer(bare_host)]),
# Dovecot should be started before Postfix # Dovecot should be started before Postfix
# because it creates authentication socket # because it creates authentication socket
# required by Postfix. # required by Postfix.

View File

@@ -5,14 +5,13 @@ from chatmaild.config import Config
from pyinfra import host from pyinfra import host
from pyinfra.facts.deb import DebPackages from pyinfra.facts.deb import DebPackages
from pyinfra.facts.server import Arch, Command, Sysctl from pyinfra.facts.server import Arch, Command, Sysctl
from pyinfra.operations import apt, files, server, systemd from pyinfra.operations import apt, files, server
from cmdeploy.basedeploy import ( from cmdeploy.basedeploy import (
Deployer, Deployer,
activate_remote_units, activate_remote_units,
blocked_service_startup, blocked_service_startup,
configure_remote_units, configure_remote_units,
get_resource,
is_in_container, is_in_container,
) )
@@ -59,26 +58,21 @@ class DovecotDeployer(Deployer):
], ],
) )
self.need_restart = True self.need_restart = True
files.put( self.put_file(
name="Pin dovecot packages to block Debian dist-upgrades",
src=io.StringIO( src=io.StringIO(
"Package: dovecot-*\n" "Package: dovecot-*\n"
"Pin: version *\n" "Pin: version *\n"
"Pin-Priority: -1\n" "Pin-Priority: -1\n"
), ),
dest="/etc/apt/preferences.d/pin-dovecot", dest="/etc/apt/preferences.d/pin-dovecot",
user="root",
group="root",
mode="644",
) )
def configure(self): def configure(self):
configure_remote_units(self.config.mail_domain, self.units) configure_remote_units(self, self.config.mail_domain_bare, self.units)
config_restart, self.daemon_reload = _configure_dovecot(self.config) _configure_dovecot(self, self.config)
self.need_restart |= config_restart
def activate(self): def activate(self):
activate_remote_units(self.units) activate_remote_units(self, self.units)
# Detect stale binary: package installed but service still runs old (deleted) binary. # Detect stale binary: package installed but service still runs old (deleted) binary.
if not self.disable_mail and not self.need_restart: if not self.disable_mail and not self.need_restart:
@@ -91,19 +85,12 @@ class DovecotDeployer(Deployer):
if stale == "STALE": if stale == "STALE":
self.need_restart = True self.need_restart = True
restart = False if self.disable_mail else self.need_restart active = not self.disable_mail
self.ensure_service(
systemd.service( "dovecot.service",
name="Disable dovecot for now" running=active,
if self.disable_mail enabled=active,
else "Start and enable Dovecot",
service="dovecot.service",
running=False if self.disable_mail else True,
enabled=False if self.disable_mail else True,
restarted=restart,
daemon_reload=self.daemon_reload,
) )
self.need_restart = False
def _pick_url(primary, fallback): def _pick_url(primary, fallback):
@@ -147,39 +134,19 @@ def _download_dovecot_package(package: str, arch: str) -> tuple[str | None, bool
return deb_filename, True return deb_filename, True
def _configure_dovecot(deployer, config: Config, debug: bool = False):
def _configure_dovecot(config: Config, debug: bool = False) -> tuple[bool, bool]:
"""Configures Dovecot IMAP server.""" """Configures Dovecot IMAP server."""
need_restart = False deployer.put_template(
daemon_reload = False "dovecot/dovecot.conf.j2",
"/etc/dovecot/dovecot.conf",
main_config = files.template(
src=get_resource("dovecot/dovecot.conf.j2"),
dest="/etc/dovecot/dovecot.conf",
user="root",
group="root",
mode="644",
config=config, config=config,
debug=debug, debug=debug,
disable_ipv6=config.disable_ipv6, disable_ipv6=config.disable_ipv6,
) )
need_restart |= main_config.changed deployer.put_file("dovecot/auth.conf", "/etc/dovecot/auth.conf")
auth_config = files.put( deployer.put_file(
src=get_resource("dovecot/auth.conf"), "dovecot/push_notification.lua", "/etc/dovecot/push_notification.lua"
dest="/etc/dovecot/auth.conf",
user="root",
group="root",
mode="644",
) )
need_restart |= auth_config.changed
lua_push_notification_script = files.put(
src=get_resource("dovecot/push_notification.lua"),
dest="/etc/dovecot/push_notification.lua",
user="root",
group="root",
mode="644",
)
need_restart |= lua_push_notification_script.changed
# as per https://doc.dovecot.org/2.3/configuration_manual/os/ # as per https://doc.dovecot.org/2.3/configuration_manual/os/
# it is recommended to set the following inotify limits # it is recommended to set the following inotify limits
@@ -203,25 +170,20 @@ def _configure_dovecot(config: Config, debug: bool = False) -> tuple[bool, bool]
persist=True, persist=True,
) )
timezone_env = files.line( deployer.ensure_line(
name="Set TZ environment variable", name="Set TZ environment variable",
path="/etc/environment", path="/etc/environment",
line="TZ=:/etc/localtime", line="TZ=:/etc/localtime",
) )
need_restart |= timezone_env.changed
restart_conf = files.put( deployer.put_file(
name="dovecot: restart automatically on failure", "service/10_restart_on_failure.conf",
src=get_resource("service/10_restart.conf"), "/etc/systemd/system/dovecot.service.d/10_restart.conf",
dest="/etc/systemd/system/dovecot.service.d/10_restart.conf",
) )
daemon_reload |= restart_conf.changed
# Validate dovecot configuration before restart # Validate dovecot configuration before restart
if need_restart: if deployer.need_restart:
server.shell( server.shell(
name="Validate dovecot configuration", name="Validate dovecot configuration",
commands=["doveconf -n >/dev/null"], commands=["doveconf -n >/dev/null"],
) )
return need_restart, daemon_reload

View File

@@ -7,6 +7,7 @@ listen = 0.0.0.0
protocols = imap lmtp protocols = imap lmtp
auth_mechanisms = plain auth_mechanisms = plain
auth_username_chars = abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567890.-_@[]
{% if debug == true %} {% if debug == true %}
auth_verbose = yes auth_verbose = yes

View File

@@ -1,10 +1,7 @@
import io
from pyinfra import host from pyinfra import host
from pyinfra.facts.files import File from pyinfra.facts.files import File
from pyinfra.operations import files, systemd
from cmdeploy.basedeploy import Deployer, get_resource from ..basedeploy import Deployer
class ExternalTlsDeployer(Deployer): class ExternalTlsDeployer(Deployer):
@@ -23,45 +20,22 @@ class ExternalTlsDeployer(Deployer):
def configure(self): def configure(self):
# Verify cert and key exist on the remote host using pyinfra facts. # Verify cert and key exist on the remote host using pyinfra facts.
for path in (self.cert_path, self.key_path): for path in (self.cert_path, self.key_path):
info = host.get_fact(File, path=path) if host.get_fact(File, path=path) is None:
if info is None:
raise Exception(f"External TLS file not found on server: {path}") raise Exception(f"External TLS file not found on server: {path}")
# Deploy the .path unit (templated with the cert path). self.ensure_systemd_unit(
# pkg=__package__ is required here because the resource files "external/tls-cert-reload.path.j2",
# live in cmdeploy.external, not the default cmdeploy package. cert_path=self.cert_path,
source = get_resource("tls-cert-reload.path.f", pkg=__package__)
content = source.read_text().format(cert_path=self.cert_path).encode()
path_unit = files.put(
name="Upload tls-cert-reload.path",
src=io.BytesIO(content),
dest="/etc/systemd/system/tls-cert-reload.path",
user="root",
group="root",
mode="644",
) )
self.ensure_systemd_unit(
service_unit = files.put( "external/tls-cert-reload.service",
name="Upload tls-cert-reload.service",
src=get_resource("tls-cert-reload.service", pkg=__package__),
dest="/etc/systemd/system/tls-cert-reload.service",
user="root",
group="root",
mode="644",
) )
if path_unit.changed or service_unit.changed:
self.need_restart = True
def activate(self): def activate(self):
systemd.service(
name="Enable tls-cert-reload path watcher",
service="tls-cert-reload.path",
running=True,
enabled=True,
restarted=self.need_restart,
daemon_reload=self.need_restart,
)
# No explicit reload needed here: dovecot/nginx read the cert # No explicit reload needed here: dovecot/nginx read the cert
# on startup, and the .path watcher handles live changes. # on startup, and the .path watcher handles live changes.
self.ensure_service(
"tls-cert-reload.path",
running=True,
enabled=True,
)

View File

@@ -9,7 +9,7 @@
Description=Watch TLS certificate for changes Description=Watch TLS certificate for changes
[Path] [Path]
PathChanged={cert_path} PathChanged={{ cert_path }}
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View File

@@ -1,64 +1,40 @@
import os import os
from pyinfra import facts, host from pyinfra import facts, host
from pyinfra.operations import files, systemd
from cmdeploy.basedeploy import Deployer, get_resource from cmdeploy.basedeploy import Deployer
class FiltermailDeployer(Deployer): class FiltermailDeployer(Deployer):
services = ["filtermail", "filtermail-incoming"] services = ["filtermail", "filtermail-incoming", "filtermail-transport"]
bin_path = "/usr/local/bin/filtermail" bin_path = "/usr/local/bin/filtermail"
config_path = "/usr/local/lib/chatmaild/chatmail.ini" config_path = "/usr/local/lib/chatmaild/chatmail.ini"
def __init__(self):
self.need_restart = False
def install(self): def install(self):
local_bin = os.environ.get("CHATMAIL_FILTERMAIL_BINARY") local_bin = os.environ.get("CHATMAIL_FILTERMAIL_BINARY")
if local_bin: if local_bin:
self.need_restart |= files.put( self.put_executable(
name="Upload locally built filtermail",
src=local_bin, src=local_bin,
dest=self.bin_path, dest=self.bin_path,
mode="755", )
).changed
return return
arch = host.get_fact(facts.server.Arch) arch = host.get_fact(facts.server.Arch)
url = f"https://github.com/chatmail/filtermail/releases/download/v0.6.1/filtermail-{arch}" url = f"https://github.com/chatmail/filtermail/releases/download/v0.6.6/filtermail-{arch}"
sha256sum = { sha256sum = {
"x86_64": "48b3fb80c092d00b9b0a0ef77a8673496da3b9aed5ec1851e1df936d5589d62f", "x86_64": "05c7e7ac244606c2eeb275f2d282ffdbc2403e0169f1cdd3110ffcebdb994a92",
"aarch64": "c65bd5f45df187d3d65d6965a285583a3be0f44a6916ff12909ff9a8d702c22e", "aarch64": "8cf8bbda4d907beca547b365cc7e6753532a74b1712492d0d2f3d2d8a553fb3d",
}[arch] }[arch]
self.need_restart |= files.download( self.download_executable(url, self.bin_path, sha256sum)
name="Download filtermail",
src=url,
sha256sum=sha256sum,
dest=self.bin_path,
mode="755",
).changed
def configure(self): def configure(self):
for service in self.services: for service in self.services:
self.need_restart |= files.template( self.ensure_systemd_unit(
src=get_resource(f"filtermail/{service}.service.j2"), f"filtermail/{service}.service.j2",
dest=f"/etc/systemd/system/{service}.service",
user="root",
group="root",
mode="644",
bin_path=self.bin_path, bin_path=self.bin_path,
config_path=self.config_path, config_path=self.config_path,
).changed )
def activate(self): def activate(self):
for service in self.services: for service in self.services:
systemd.service( self.ensure_service(f"{service}.service")
name=f"Start and enable {service}",
service=f"{service}.service",
running=True,
enabled=True,
restarted=self.need_restart,
daemon_reload=True,
)
self.need_restart = False

View File

@@ -0,0 +1,11 @@
[Unit]
Description=Chatmail transport service
[Service]
ExecStart={{ bin_path }} {{ config_path }} transport
Restart=always
RestartSec=30
User=vmail
[Install]
WantedBy=multi-user.target

View File

@@ -1,10 +1,7 @@
from pyinfra import facts, host from pyinfra import facts, host
from pyinfra.operations import apt, files, server, systemd from pyinfra.operations import apt
from cmdeploy.basedeploy import ( from cmdeploy.basedeploy import Deployer
Deployer,
get_resource,
)
class MtailDeployer(Deployer): class MtailDeployer(Deployer):
@@ -18,51 +15,30 @@ class MtailDeployer(Deployer):
(url, sha256sum) = { (url, sha256sum) = {
"x86_64": ( "x86_64": (
"https://github.com/google/mtail/releases/download/v3.0.8/mtail_3.0.8_linux_amd64.tar.gz", "https://github.com/google/mtail/releases/download/v3.0.8/mtail_3.0.8_linux_amd64.tar.gz",
"123c2ee5f48c3eff12ebccee38befd2233d715da736000ccde49e3d5607724e4", "d55cb601049c5e61eabab29998dbbcea95d480e5448544f9470337ba2eea882e",
), ),
"aarch64": ( "aarch64": (
"https://github.com/google/mtail/releases/download/v3.0.8/mtail_3.0.8_linux_arm64.tar.gz", "https://github.com/google/mtail/releases/download/v3.0.8/mtail_3.0.8_linux_arm64.tar.gz",
"aa04811c0929b6754408676de520e050c45dddeb3401881888a092c9aea89cae", "f748db8ad2a1e0b63684d4c8868cf6a373a20f7e6922e5ece601fff0ee00eb1a",
), ),
}[host.get_fact(facts.server.Arch)] }[host.get_fact(facts.server.Arch)]
self.download_executable(
server.shell( url,
name="Download mtail", "/usr/local/bin/mtail",
commands=[ sha256sum,
f"(echo '{sha256sum} /usr/local/bin/mtail' | sha256sum -c) || (curl -L {url} | gunzip | tar -x -f - mtail -O >/usr/local/bin/mtail.new && mv /usr/local/bin/mtail.new /usr/local/bin/mtail)", extract="gunzip | tar -xf - mtail -O",
"chmod 755 /usr/local/bin/mtail",
],
) )
def configure(self): def configure(self):
# Using our own systemd unit instead of `/usr/lib/systemd/system/mtail.service`. # Using our own systemd unit instead of `/usr/lib/systemd/system/mtail.service`.
# This allows to read from journalctl instead of log files. # This allows to read from journalctl instead of log files.
files.template( self.ensure_systemd_unit(
src=get_resource("mtail/mtail.service.j2"), "mtail/mtail.service.j2",
dest="/etc/systemd/system/mtail.service",
user="root",
group="root",
mode="644",
address=self.mtail_address or "127.0.0.1", address=self.mtail_address or "127.0.0.1",
port=3903, port=3903,
) )
self.put_file("mtail/delivered_mail.mtail", "/etc/mtail/delivered_mail.mtail")
mtail_conf = files.put(
name="Mtail configuration",
src=get_resource("mtail/delivered_mail.mtail"),
dest="/etc/mtail/delivered_mail.mtail",
user="root",
group="root",
mode="644",
)
self.need_restart = mtail_conf.changed
def activate(self): def activate(self):
systemd.service( active = bool(self.mtail_address)
name="Start and enable mtail", self.ensure_service("mtail.service", running=active, enabled=active)
service="mtail.service",
running=bool(self.mtail_address),
enabled=bool(self.mtail_address),
restarted=self.need_restart,
)
self.need_restart = False

View File

@@ -1,11 +1,13 @@
[Unit] [Unit]
Description=mtail Description=mtail
After=multi-user.target After=network-online.target
Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
ExecStart=/bin/sh -c "journalctl -f -o short-iso -n 0 | /usr/local/bin/mtail --address={{ address }} --port={{ port }} --progs /etc/mtail --logtostderr --logs -" ExecStart=/bin/sh -c "journalctl -f -o short-iso -n 0 | /usr/local/bin/mtail --address={{ address }} --port={{ port }} --progs /etc/mtail --logtostderr --logs -"
Restart=on-failure Restart=on-failure
RestartSec=2s
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View File

@@ -1,5 +1,5 @@
from chatmaild.config import Config from chatmaild.config import Config
from pyinfra.operations import apt, files, systemd from pyinfra.operations import apt
from cmdeploy.basedeploy import ( from cmdeploy.basedeploy import (
Deployer, Deployer,
@@ -31,87 +31,50 @@ class NginxDeployer(Deployer):
# For documentation about policy-rc.d, see: # For documentation about policy-rc.d, see:
# https://people.debian.org/~hmh/invokerc.d-policyrc.d-specification.txt # https://people.debian.org/~hmh/invokerc.d-policyrc.d-specification.txt
# #
files.put( self.put_executable(src="policy-rc.d", dest="/usr/sbin/policy-rc.d")
src=get_resource("policy-rc.d"),
dest="/usr/sbin/policy-rc.d",
user="root",
group="root",
mode="755",
)
apt.packages( apt.packages(
name="Install nginx", name="Install nginx",
packages=["nginx", "libnginx-mod-stream"], packages=["nginx", "libnginx-mod-stream"],
) )
files.file("/usr/sbin/policy-rc.d", present=False) self.remove_file("/usr/sbin/policy-rc.d")
def configure(self): def configure(self):
self.need_restart = _configure_nginx(self.config) _configure_nginx(self, self.config)
def activate(self): def activate(self):
systemd.service( self.ensure_service("nginx.service")
name="Start and enable nginx",
service="nginx.service",
running=True,
enabled=True,
restarted=self.need_restart,
)
self.need_restart = False
def _configure_nginx(config: Config, debug: bool = False) -> bool: def _configure_nginx(deployer, config: Config, debug: bool = False):
"""Configures nginx HTTP server.""" """Configures nginx HTTP server."""
need_restart = False
main_config = files.template( deployer.put_template(
src=get_resource("nginx/nginx.conf.j2"), "nginx/nginx.conf.j2",
dest="/etc/nginx/nginx.conf", "/etc/nginx/nginx.conf",
user="root",
group="root",
mode="644",
config=config, config=config,
disable_ipv6=config.disable_ipv6, disable_ipv6=config.disable_ipv6,
) )
need_restart |= main_config.changed
autoconfig = files.template( deployer.put_template(
src=get_resource("nginx/autoconfig.xml.j2"), "nginx/autoconfig.xml.j2",
dest="/var/www/html/.well-known/autoconfig/mail/config-v1.1.xml", "/var/www/html/.well-known/autoconfig/mail/config-v1.1.xml",
user="root",
group="root",
mode="644",
config=config, config=config,
) )
need_restart |= autoconfig.changed
mta_sts_config = files.template( deployer.put_template(
src=get_resource("nginx/mta-sts.txt.j2"), "nginx/mta-sts.txt.j2",
dest="/var/www/html/.well-known/mta-sts.txt", "/var/www/html/.well-known/mta-sts.txt",
user="root",
group="root",
mode="644",
config=config, config=config,
) )
need_restart |= mta_sts_config.changed
# install CGI newemail script # install CGI newemail script
# #
cgi_dir = "/usr/lib/cgi-bin" cgi_dir = "/usr/lib/cgi-bin"
files.directory( deployer.ensure_directory(cgi_dir)
name=f"Ensure {cgi_dir} exists",
path=cgi_dir,
user="root",
group="root",
)
files.put( deployer.put_executable(
name="Upload cgi newemail.py script",
src=get_resource("newemail.py", pkg="chatmaild").open("rb"), src=get_resource("newemail.py", pkg="chatmaild").open("rb"),
dest=f"{cgi_dir}/newemail.py", dest=f"{cgi_dir}/newemail.py",
user="root",
group="root",
mode="755",
) )
return need_restart

View File

@@ -42,6 +42,9 @@ stream {
} }
http { http {
# access_log setting is inherited by all server sections
access_log syslog:server=unix:/dev/log,facility=local7;
{% if config.tls_cert_mode == "self" %} {% if config.tls_cert_mode == "self" %}
limit_req_zone $binary_remote_addr zone=newaccount:10m rate=2r/s; limit_req_zone $binary_remote_addr zone=newaccount:10m rate=2r/s;
{% endif %} {% endif %}
@@ -69,11 +72,9 @@ http {
index index.html index.htm; index index.html index.htm;
server_name {{ config.mail_domain }} www.{{ config.mail_domain }} mta-sts.{{ config.mail_domain }}; server_name {{ config.mail_domain }} mta-sts.{{ config.mail_domain }};
access_log syslog:server=unix:/dev/log,facility=local7; location /mxdeliv {
location /mxdeliv/ {
proxy_pass http://127.0.0.1:{{ config.filtermail_http_port_incoming }}; proxy_pass http://127.0.0.1:{{ config.filtermail_http_port_incoming }};
} }
@@ -143,7 +144,6 @@ http {
listen 127.0.0.1:8443 ssl; listen 127.0.0.1:8443 ssl;
server_name www.{{ config.mail_domain }}; server_name www.{{ config.mail_domain }};
return 301 $scheme://{{ config.mail_domain }}$request_uri; return 301 $scheme://{{ config.mail_domain }}$request_uri;
access_log syslog:server=unix:/dev/log,facility=local7;
} }
server { server {

View File

@@ -4,9 +4,9 @@ Installs OpenDKIM
from pyinfra import host from pyinfra import host
from pyinfra.facts.files import File from pyinfra.facts.files import File
from pyinfra.operations import apt, files, server, systemd from pyinfra.operations import apt, files, server
from cmdeploy.basedeploy import Deployer, get_resource from cmdeploy.basedeploy import Deployer
class OpendkimDeployer(Deployer): class OpendkimDeployer(Deployer):
@@ -25,65 +25,39 @@ class OpendkimDeployer(Deployer):
domain = self.mail_domain domain = self.mail_domain
dkim_selector = "opendkim" dkim_selector = "opendkim"
"""Configures OpenDKIM""" """Configures OpenDKIM"""
need_restart = False
main_config = files.template( self.put_template(
src=get_resource("opendkim/opendkim.conf"), "opendkim/opendkim.conf",
dest="/etc/opendkim.conf", "/etc/opendkim.conf",
user="root",
group="root",
mode="644",
config={"domain_name": domain, "opendkim_selector": dkim_selector}, config={"domain_name": domain, "opendkim_selector": dkim_selector},
) )
need_restart |= main_config.changed
screen_script = files.file( self.remove_file("/etc/opendkim/screen.lua")
path="/etc/opendkim/screen.lua", self.remove_file("/etc/opendkim/final.lua")
present=False,
)
need_restart |= screen_script.changed
final_script = files.file( self.ensure_directory(
path="/etc/opendkim/final.lua", "/etc/opendkim",
present=False, owner="opendkim",
)
need_restart |= final_script.changed
files.directory(
name="Add opendkim directory to /etc",
path="/etc/opendkim",
user="opendkim",
group="opendkim",
mode="750", mode="750",
present=True,
) )
keytable = files.template( self.put_template(
src=get_resource("opendkim/KeyTable"), "opendkim/KeyTable",
dest="/etc/dkimkeys/KeyTable", "/etc/dkimkeys/KeyTable",
user="opendkim", owner="opendkim",
group="opendkim",
mode="644",
config={"domain_name": domain, "opendkim_selector": dkim_selector}, config={"domain_name": domain, "opendkim_selector": dkim_selector},
) )
need_restart |= keytable.changed
signing_table = files.template( self.put_template(
src=get_resource("opendkim/SigningTable"), "opendkim/SigningTable",
dest="/etc/dkimkeys/SigningTable", "/etc/dkimkeys/SigningTable",
user="opendkim", owner="opendkim",
group="opendkim",
mode="644",
config={"domain_name": domain, "opendkim_selector": dkim_selector}, config={"domain_name": domain, "opendkim_selector": dkim_selector},
) )
need_restart |= signing_table.changed self.ensure_directory(
files.directory( "/var/spool/postfix/opendkim",
name="Add opendkim socket directory to /var/spool/postfix", owner="opendkim",
path="/var/spool/postfix/opendkim",
user="opendkim",
group="opendkim",
mode="750", mode="750",
present=True,
) )
if not host.get_fact(File, f"/etc/dkimkeys/{dkim_selector}.private"): if not host.get_fact(File, f"/etc/dkimkeys/{dkim_selector}.private"):
@@ -96,12 +70,10 @@ class OpendkimDeployer(Deployer):
_su_user="opendkim", _su_user="opendkim",
) )
service_file = files.put( self.put_file(
name="Configure opendkim to restart once a day", "opendkim/systemd.conf",
src=get_resource("opendkim/systemd.conf"), "/etc/systemd/system/opendkim.service.d/10-prevent-memory-leak.conf",
dest="/etc/systemd/system/opendkim.service.d/10-prevent-memory-leak.conf",
) )
need_restart |= service_file.changed
files.file( files.file(
name="chown opendkim: /etc/dkimkeys/opendkim.private", name="chown opendkim: /etc/dkimkeys/opendkim.private",
@@ -110,15 +82,5 @@ class OpendkimDeployer(Deployer):
group="opendkim", group="opendkim",
) )
self.need_restart = need_restart
def activate(self): def activate(self):
systemd.service( self.ensure_service("opendkim.service")
name="Start and enable OpenDKIM",
service="opendkim.service",
running=True,
enabled=True,
daemon_reload=self.need_restart,
restarted=self.need_restart,
)
self.need_restart = False

View File

@@ -1,11 +1,10 @@
from pyinfra.operations import apt, files, server, systemd from pyinfra.operations import apt, server
from cmdeploy.basedeploy import Deployer, get_resource from cmdeploy.basedeploy import Deployer
class PostfixDeployer(Deployer): class PostfixDeployer(Deployer):
required_users = [("postfix", None, ["opendkim"])] required_users = [("postfix", None, ["opendkim"])]
daemon_reload = False
def __init__(self, config, disable_mail): def __init__(self, config, disable_mail):
self.config = config self.config = config
@@ -19,81 +18,46 @@ class PostfixDeployer(Deployer):
def configure(self): def configure(self):
config = self.config config = self.config
need_restart = False
main_config = files.template( self.put_template(
src=get_resource("postfix/main.cf.j2"), "postfix/main.cf.j2",
dest="/etc/postfix/main.cf", "/etc/postfix/main.cf",
user="root",
group="root",
mode="644",
config=config, config=config,
disable_ipv6=config.disable_ipv6, disable_ipv6=config.disable_ipv6,
) )
need_restart |= main_config.changed
master_config = files.template( self.put_template(
src=get_resource("postfix/master.cf.j2"), "postfix/master.cf.j2",
dest="/etc/postfix/master.cf", "/etc/postfix/master.cf",
user="root",
group="root",
mode="644",
debug=False, debug=False,
config=config, config=config,
) )
need_restart |= master_config.changed
header_cleanup = files.put( self.put_file(
src=get_resource("postfix/submission_header_cleanup"), "postfix/submission_header_cleanup",
dest="/etc/postfix/submission_header_cleanup", "/etc/postfix/submission_header_cleanup",
user="root",
group="root",
mode="644",
) )
need_restart |= header_cleanup.changed self.put_file("postfix/lmtp_header_cleanup", "/etc/postfix/lmtp_header_cleanup")
lmtp_header_cleanup = files.put( res = self.put_file(
src=get_resource("postfix/lmtp_header_cleanup"), "postfix/smtp_tls_policy_map", "/etc/postfix/smtp_tls_policy_map"
dest="/etc/postfix/lmtp_header_cleanup",
user="root",
group="root",
mode="644",
) )
need_restart |= lmtp_header_cleanup.changed tls_policy_changed = res.changed
if tls_policy_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( server.shell(
commands=["postmap /etc/postfix/smtp_tls_policy_map"], 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( self.put_file("postfix/login_map", "/etc/postfix/login_map")
src=get_resource("postfix/login_map"),
dest="/etc/postfix/login_map",
user="root",
group="root",
mode="644",
)
need_restart |= login_map.changed
restart_conf = files.put( self.put_file(
name="postfix: restart automatically on failure", "service/10_restart_on_failure.conf",
src=get_resource("service/10_restart.conf"), "/etc/systemd/system/postfix@.service.d/10_restart.conf",
dest="/etc/systemd/system/postfix@.service.d/10_restart.conf",
) )
self.daemon_reload = restart_conf.changed
# Validate postfix configuration before restart # Validate postfix configuration before restart
if need_restart: if self.need_restart:
server.shell( server.shell(
name="Validate postfix configuration", name="Validate postfix configuration",
# Extract stderr and quit with error if non-zero # Extract stderr and quit with error if non-zero
@@ -101,19 +65,11 @@ class PostfixDeployer(Deployer):
"""bash -c 'w=$(postconf 2>&1 >/dev/null); [[ -z "$w" ]] || { echo "$w"; false; }'""" """bash -c 'w=$(postconf 2>&1 >/dev/null); [[ -z "$w" ]] || { echo "$w"; false; }'"""
], ],
) )
self.need_restart = need_restart
def activate(self): def activate(self):
restart = False if self.disable_mail else self.need_restart active = not self.disable_mail
self.ensure_service(
systemd.service( "postfix.service",
name="disable postfix for now" running=active,
if self.disable_mail enabled=active,
else "Start and enable Postfix",
service="postfix.service",
running=False if self.disable_mail else True,
enabled=False if self.disable_mail else True,
restarted=restart,
daemon_reload=self.daemon_reload,
) )
self.need_restart = False

View File

@@ -54,14 +54,17 @@ smtpd_tls_exclude_ciphers = aNULL, RC4, MD5, DES
tls_preempt_cipherlist = yes tls_preempt_cipherlist = yes
smtpd_relay_restrictions = permit_mynetworks permit_sasl_authenticated defer_unauth_destination smtpd_relay_restrictions = permit_mynetworks permit_sasl_authenticated defer_unauth_destination
myhostname = {{ config.mail_domain }} myhostname = {{ config.postfix_myhostname }}
alias_maps = hash:/etc/aliases alias_maps = hash:/etc/aliases
alias_database = hash:/etc/aliases alias_database = hash:/etc/aliases
# Postfix does not deliver mail for any domain by itself. # When postfix receives mail for $mydestination,
# Primary domain is listed in `virtual_mailbox_domains` instead # it hands it over to dovecot via $local_transport.
# and handed over to Dovecot. # Note: IP literals must be handled via local delivery / mydestination.
mydestination = mydestination = {{ config.mail_domain }}
local_transport = lmtp:unix:private/dovecot-lmtp
# postfix doesn't check whether local users exist or not:
local_recipient_maps =
relayhost = relayhost =
{% if disable_ipv6 %} {% if disable_ipv6 %}
@@ -79,24 +82,6 @@ inet_protocols = ipv4
inet_protocols = all inet_protocols = all
{% endif %} {% endif %}
# Postfix does not try IPv4 and IPv6 connections
# concurrently as of version 3.7.11.
#
# When relay has both A (IPv4) and AAAA (IPv6) records,
# but broken IPv6 connectivity,
# every second message is delayed by the connection timeout
# <https://www.postfix.org/postconf.5.html#smtp_connect_timeout>
# which defaults to 30 seconds. Reducing timeouts is not a solution
# as this will result in a failure to connect to slow servers.
#
# As a workaround we always prefer IPv4 when it is available.
#
# The setting is documented at
# <https://www.postfix.org/postconf.5.html#smtp_address_preference>
smtp_address_preference=ipv4
virtual_transport = lmtp:unix:private/dovecot-lmtp
virtual_mailbox_domains = {{ config.mail_domain }}
lmtp_header_checks = regexp:/etc/postfix/lmtp_header_cleanup lmtp_header_checks = regexp:/etc/postfix/lmtp_header_cleanup
mua_client_restrictions = permit_sasl_authenticated, reject mua_client_restrictions = permit_sasl_authenticated, reject
@@ -109,3 +94,20 @@ smtpd_sender_login_maps = regexp:/etc/postfix/login_map
# Do not lookup SMTP client hostnames to reduce delays # Do not lookup SMTP client hostnames to reduce delays
# and avoid unnecessary DNS requests. # and avoid unnecessary DNS requests.
smtpd_peername_lookup = no smtpd_peername_lookup = no
# Use filtermail-transport to relay messages.
# We can't force postfix to split messages per destination,
# when specifying a custom next-hop,
# so instead this is handled in filtermail.
# We use LMTP instead SMTP so we can communicate per-recipient errors back to postfix.
default_transport = lmtp-filtermail:inet:[127.0.0.1]:{{ config.filtermail_lmtp_port_transport }}
lmtp-filtermail_initial_destination_concurrency=10000
lmtp-filtermail_destination_concurrency_limit=10000
{% if not config.ipv4_relay %}
# DKIM-sign locally generated mail (bounces, DSNs).
# These bypass smtpd, so they need explicit milter configuration.
non_smtpd_milters = unix:opendkim/opendkim.sock
internal_mail_filter_classes = bounce
milter_macro_daemon_name = ORIGINATING
{% endif %}

View File

@@ -80,8 +80,9 @@ filter unix - n n - - lmtp
127.0.0.1:{{ config.postfix_reinject_port }} inet n - n - 100 smtpd 127.0.0.1:{{ config.postfix_reinject_port }} inet n - n - 100 smtpd
-o syslog_name=postfix/reinject -o syslog_name=postfix/reinject
-o milter_macro_daemon_name=ORIGINATING -o milter_macro_daemon_name=ORIGINATING
-o smtpd_milters=unix:opendkim/opendkim.sock
-o cleanup_service_name=authclean -o cleanup_service_name=authclean
{% if not config.ipv4_relay %} -o smtpd_milters=unix:opendkim/opendkim.sock
{% endif %}
# Local SMTP server for reinjecting incoming filtered mail # Local SMTP server for reinjecting incoming filtered mail
127.0.0.1:{{ config.postfix_reinject_port_incoming }} inet n - n - 100 smtpd 127.0.0.1:{{ config.postfix_reinject_port_incoming }} inet n - n - 100 smtpd
@@ -100,3 +101,8 @@ filter unix - n n - - lmtp
# cannot send unprotected Subject. # cannot send unprotected Subject.
authclean unix n - - - 0 cleanup authclean unix n - - - 0 cleanup
-o header_checks=regexp:/etc/postfix/submission_header_cleanup -o header_checks=regexp:/etc/postfix/submission_header_cleanup
lmtp-filtermail unix - - y - 10000 lmtp
-o syslog_name=postfix/lmtp-filtermail
-o lmtp_header_checks=
-o lmtp_tls_security_level=none

View File

@@ -64,21 +64,25 @@ def get_dkim_entry(mail_domain, pre_command, dkim_selector):
) )
def query_dns(typ, domain): def get_authoritative_ns(domain):
# Get autoritative nameserver from the SOA record. ns_replies = [
soa_answers = [
x.split() x.split()
for x in shell( for x in shell(
f"dig -r -q {domain} -t SOA +noall +authority +answer", print=log_progress f"dig -r -q {domain} -t NS +noall +authority +answer", print=log_progress
).split("\n") ).split("\n")
] ]
soa = [a for a in soa_answers if len(a) >= 3 and a[3] == "SOA"] filtered_replies = [a for a in ns_replies if len(a) >= 5 and a[3] == "NS"]
if not soa: if not filtered_replies:
return return
ns = soa[0][4] return filtered_replies[0][4]
def query_dns(typ, domain):
ns = get_authoritative_ns(domain)
# Query authoritative nameserver directly to bypass DNS cache. # Query authoritative nameserver directly to bypass DNS cache.
res = shell(f"dig @{ns} -r -q {domain} -t {typ} +short", print=log_progress) direct_ns = f"@{ns}" if ns else ""
res = shell(f"dig {direct_ns} -r -q {domain} -t {typ} +short", print=log_progress)
return next((line for line in res.split("\n") if not line.startswith(";")), "") return next((line for line in res.split("\n") if not line.startswith(";")), "")

View File

@@ -1,8 +1,8 @@
import shlex import shlex
from pyinfra.operations import apt, server from pyinfra.operations import server
from cmdeploy.basedeploy import Deployer from ..basedeploy import Deployer
def openssl_selfsigned_args(domain, cert_path, key_path, days=36500): def openssl_selfsigned_args(domain, cert_path, key_path, days=36500):
@@ -34,11 +34,7 @@ class SelfSignedTlsDeployer(Deployer):
self.cert_path = "/etc/ssl/certs/mailserver.pem" self.cert_path = "/etc/ssl/certs/mailserver.pem"
self.key_path = "/etc/ssl/private/mailserver.key" self.key_path = "/etc/ssl/private/mailserver.key"
def install(self):
apt.packages(
name="Install openssl",
packages=["openssl"],
)
def configure(self): def configure(self):
args = openssl_selfsigned_args( args = openssl_selfsigned_args(
@@ -52,3 +48,5 @@ class SelfSignedTlsDeployer(Deployer):
def activate(self): def activate(self):
pass pass

View File

@@ -12,7 +12,7 @@ def test_init(tmp_path, maildomain):
inipath = tmp_path.joinpath("chatmail.ini") inipath = tmp_path.joinpath("chatmail.ini")
main(["init", "--config", str(inipath), maildomain]) main(["init", "--config", str(inipath), maildomain])
config = read_config(inipath) config = read_config(inipath)
assert config.mail_domain == maildomain assert config.mail_domain_bare == maildomain
def test_capabilities(imap): def test_capabilities(imap):
@@ -89,12 +89,11 @@ def test_concurrent_logins_same_account(
assert login_results.get() assert login_results.get()
def test_no_vrfy(cmfactory, chatmail_config): def test_no_vrfy(cmfactory, chatmail_config, maildomain):
ac = cmfactory.get_online_account() ac = cmfactory.get_online_account()
addr = ac.get_config("addr") addr = ac.get_config("addr")
domain = chatmail_config.mail_domain
s = smtplib.SMTP(domain) s = smtplib.SMTP(maildomain)
s.starttls() s.starttls()
s.putcmd("vrfy", f"wrongaddress@{chatmail_config.mail_domain}") s.putcmd("vrfy", f"wrongaddress@{chatmail_config.mail_domain}")

View File

@@ -5,6 +5,7 @@ import subprocess
import time import time
import pytest import pytest
from chatmaild.config import is_valid_ipv4
from cmdeploy import remote from cmdeploy import remote
from cmdeploy.cmdeploy import get_sshexec from cmdeploy.cmdeploy import get_sshexec
@@ -21,6 +22,8 @@ class TestSSHExecutor:
assert out == out2 assert out == out2
def test_perform_initial(self, sshexec, maildomain): def test_perform_initial(self, sshexec, maildomain):
if is_valid_ipv4(maildomain):
pytest.skip(f"{maildomain} is not a domain")
res = sshexec( res = sshexec(
remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=maildomain) remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=maildomain)
) )
@@ -61,8 +64,10 @@ class TestSSHExecutor:
else: else:
pytest.fail("didn't raise exception") pytest.fail("didn't raise exception")
def test_opendkim_restarted(self, sshexec): def test_opendkim_restarted(self, sshexec, maildomain):
"""check that opendkim is not running for longer than a day.""" """check that opendkim is not running for longer than a day."""
if is_valid_ipv4(maildomain):
pytest.skip(f"{maildomain} is an IPv4 relay, opendkim is not installed")
cmd = "systemctl show opendkim --timestamp=utc --property=ActiveEnterTimestamp" cmd = "systemctl show opendkim --timestamp=utc --property=ActiveEnterTimestamp"
out = sshexec(call=remote.rshell.shell, kwargs=dict(command=cmd)) out = sshexec(call=remote.rshell.shell, kwargs=dict(command=cmd))
datestring = out.split("=")[1] datestring = out.split("=")[1]
@@ -189,6 +194,34 @@ def test_reject_missing_dkim(cmsetup, maildata, from_addr):
s.sendmail(from_addr=from_addr, to_addrs=recipient.addr, msg=msg) s.sendmail(from_addr=from_addr, to_addrs=recipient.addr, msg=msg)
def test_bounces_are_dkim_signed(cmsetup, cmsetup2, maildata, maildomain):
# we send a message to non-existant user and expect a bounce message
# which will only get through if the bounce message was DKIM-signed
if is_valid_ipv4(maildomain):
pytest.skip("DKIM is not configured on IPv4-only relays")
sender = cmsetup2.gen_users(1)[0]
nonexistent = f"nosuchuser_test42@{cmsetup.maildomain}"
msg = maildata(
"encrypted.eml",
from_addr=sender.addr,
to_addr=nonexistent,
).as_string()
sender.smtp.sendmail(sender.addr, [nonexistent], msg)
def bounce_in_inbox():
messages = sender.imap.fetch_all_messages()
for m in messages:
if "mail delivery" in m.lower() or "undelivered" in m.lower():
return m
raise ValueError("bounce not yet in inbox")
bounce = try_n_times(30, bounce_in_inbox)
assert "nosuchuser_test42" in bounce
def try_n_times(n, f): def try_n_times(n, f):
for _ in range(n - 1): for _ in range(n - 1):
try: try:
@@ -281,3 +314,15 @@ def test_deployed_state(remote):
# assert len(git_status) == len(remote_version) # for some reason, we only get 11 lines from remote.iter_output() # assert len(git_status) == len(remote_version) # for some reason, we only get 11 lines from remote.iter_output()
for i in range(len(remote_version)): for i in range(len(remote_version)):
assert git_status[i] == remote_version[i], "You have undeployed changes." assert git_status[i] == remote_version[i], "You have undeployed changes."
def test_nginx_access_log_only_defined_once(sshdomain):
sshexec = get_sshexec(sshdomain)
conf = sshexec(
call=remote.rshell.shell,
kwargs=dict(command="nginx -T 2>/dev/null"),
)
access_logs = [l for l in conf.splitlines() if l.strip().startswith("access_log")]
assert len(access_logs) == 1, (
f"expected 1 access_log, found {len(access_logs)}: {access_logs}"
)

View File

@@ -15,7 +15,7 @@ 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")
host = user.split("@")[1] host = user.split("@")[1].strip("[").strip("]")
mailbox = imap_tools.MailBox(host, ssl_context=ssl_context) 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
@@ -178,7 +178,7 @@ def test_hide_senders_ip_address(cmfactory, ssl_context):
chat.send_text("testing submission header cleanup") chat.send_text("testing submission header cleanup")
user2.wait_for_incoming_msg() user2.wait_for_incoming_msg()
addr = user2.get_config("addr") addr = user2.get_config("addr")
host = addr.split("@")[1] host = addr.split("@")[1].strip("[").strip("]")
pw = user2.get_config("mail_pw") pw = user2.get_config("mail_pw")
mailbox = imap_tools.MailBox(host, ssl_context=ssl_context) mailbox = imap_tools.MailBox(host, ssl_context=ssl_context)
mailbox.login(addr, pw) mailbox.login(addr, pw)

View File

@@ -1,5 +1,4 @@
import imaplib import imaplib
import ipaddress
import itertools import itertools
import os import os
import random import random
@@ -10,19 +9,20 @@ import time
from pathlib import Path from pathlib import Path
import pytest import pytest
from chatmaild.config import read_config from chatmaild.config import is_valid_ipv4, read_config
from domain_validator import DomainValidator
def format_mail_domain(raw_domain: str) -> str:
if is_valid_ipv4(raw_domain):
return f"[{raw_domain}]"
DomainValidator().validate_domain_re(raw_domain)
return raw_domain
conftestdir = Path(__file__).parent conftestdir = Path(__file__).parent
def _is_ip(domain):
try:
ipaddress.ip_address(domain)
return True
except ValueError:
return False
def pytest_configure(config): def pytest_configure(config):
config._benchresults = {} config._benchresults = {}
config.addinivalue_line( config.addinivalue_line(
@@ -58,7 +58,7 @@ def chatmail_config(pytestconfig):
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def maildomain(chatmail_config): def maildomain(chatmail_config):
return chatmail_config.mail_domain return chatmail_config.mail_domain_bare
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
@@ -278,7 +278,6 @@ def gencreds(chatmail_config):
def gen(domain=None): def gen(domain=None):
domain = domain if domain else chatmail_config.mail_domain domain = domain if domain else chatmail_config.mail_domain
addr_domain = f"[{domain}]" if _is_ip(domain) else domain
while 1: while 1:
num = next(count) num = next(count)
alphanumeric = "abcdefghijklmnopqrstuvwxyz1234567890" alphanumeric = "abcdefghijklmnopqrstuvwxyz1234567890"
@@ -292,7 +291,7 @@ def gencreds(chatmail_config):
password = "".join( password = "".join(
random.choices(alphanumeric, k=chatmail_config.password_min_length) random.choices(alphanumeric, k=chatmail_config.password_min_length)
) )
yield f"{user}@{addr_domain}", f"{password}" yield f"{user}@{domain}", f"{password}"
return lambda domain=None: next(gen(domain)) return lambda domain=None: next(gen(domain))
@@ -317,7 +316,8 @@ class ChatmailACFactory:
def _make_transport(self, domain): def _make_transport(self, domain):
"""Build a transport config dict for the given domain.""" """Build a transport config dict for the given domain."""
addr, password = self.gencreds(domain) domain_deliverable = format_mail_domain(domain)
addr, password = self.gencreds(domain_deliverable)
transport = { transport = {
"addr": addr, "addr": addr,
"password": password, "password": password,
@@ -326,7 +326,7 @@ class ChatmailACFactory:
"imapServer": domain, "imapServer": domain,
"smtpServer": domain, "smtpServer": domain,
} }
if self.chatmail_config.tls_cert_mode == "self": if domain.startswith("_") or is_valid_ipv4(domain):
transport["certificateChecks"] = "acceptInvalidCertificates" transport["certificateChecks"] = "acceptInvalidCertificates"
return transport return transport
@@ -341,8 +341,9 @@ class ChatmailACFactory:
accounts = [] accounts = []
for _ in range(num): for _ in range(num):
account = self.dc.add_account() account = self.dc.add_account()
addr, password = self.gencreds(domain) domain_deliverable = format_mail_domain(domain)
if _is_ip(domain): addr, password = self.gencreds(domain_deliverable)
if is_valid_ipv4(domain):
# Use DCLOGIN scheme with explicit server hosts, # Use DCLOGIN scheme with explicit server hosts,
# matching how madmail presents its addresses to users. # matching how madmail presents its addresses to users.
qr = ( qr = (
@@ -416,10 +417,10 @@ class Remote:
def iter_output(self, logcmd="", ready=None): def iter_output(self, logcmd="", ready=None):
getjournal = "journalctl -f" if not logcmd else logcmd getjournal = "journalctl -f" if not logcmd else logcmd
print(self.sshdomain) print(self.sshdomain)
match self.sshdomain: if self.sshdomain in ("@local", "localhost"):
case "@local": command = [] command = []
case "localhost": command = [] else:
case _: command = ["ssh", f"root@{self.sshdomain}"] command = ["ssh", f"root@{self.sshdomain}"]
[command.append(arg) for arg in getjournal.split()] [command.append(arg) for arg in getjournal.split()]
popen = subprocess.Popen( popen = subprocess.Popen(
command, command,
@@ -466,6 +467,11 @@ def cmsetup(maildomain, gencreds, ssl_context):
return CMSetup(maildomain, gencreds, ssl_context) return CMSetup(maildomain, gencreds, ssl_context)
@pytest.fixture
def cmsetup2(maildomain2, gencreds, ssl_context):
return CMSetup(maildomain2, gencreds, ssl_context)
class CMSetup: class CMSetup:
def __init__(self, maildomain, gencreds, ssl_context): def __init__(self, maildomain, gencreds, ssl_context):
self.maildomain = maildomain self.maildomain = maildomain
@@ -476,7 +482,7 @@ class CMSetup:
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(format_mail_domain(self.maildomain))
user = CMUser(self.maildomain, addr, password, self.ssl_context) user = CMUser(self.maildomain, addr, password, self.ssl_context)
assert user.smtp assert user.smtp
users.append(user) users.append(user)

View File

@@ -0,0 +1,118 @@
from unittest.mock import MagicMock, patch
from cmdeploy.basedeploy import Deployer
def test_put_file_restart_and_reload():
deployer = Deployer()
mock_res = MagicMock()
mock_res.changed = True
with patch("cmdeploy.basedeploy.files.put", return_value=mock_res):
deployer.put_file("foo.conf", "/etc/foo.conf")
assert deployer.need_restart is True
assert deployer.daemon_reload is False
deployer = Deployer()
deployer.put_file("test.service", "/etc/systemd/system/test.service")
assert deployer.need_restart is True
assert deployer.daemon_reload is True
def test_remove_file():
deployer = Deployer()
mock_res = MagicMock()
mock_res.changed = True
with patch("cmdeploy.basedeploy.files.file", return_value=mock_res) as mock_file:
deployer.remove_file("/etc/foo.conf")
mock_file.assert_called_once_with(
name="Remove /etc/foo.conf", path="/etc/foo.conf", present=False
)
assert deployer.need_restart is True
def test_ensure_systemd_unit():
deployer = Deployer()
mock_res = MagicMock()
mock_res.changed = True
# Plain service file
with patch("cmdeploy.basedeploy.files.put", return_value=mock_res) as mock_put:
deployer.ensure_systemd_unit("iroh-relay.service")
assert (
mock_put.call_args.kwargs["dest"]
== "/etc/systemd/system/iroh-relay.service"
)
assert deployer.need_restart is True
assert deployer.daemon_reload is True
deployer = Deployer()
# Template (.j2) dispatches to put_template and strips .j2 suffix
with patch("cmdeploy.basedeploy.files.template", return_value=mock_res) as mock_tpl:
deployer.ensure_systemd_unit(
"filtermail/chatmaild.service.j2",
bin_path="/usr/local/bin/filtermail",
)
assert (
mock_tpl.call_args.kwargs["dest"] == "/etc/systemd/system/chatmaild.service"
)
deployer = Deployer()
# Explicit dest_name override
with patch("cmdeploy.basedeploy.files.put", return_value=mock_res) as mock_put:
deployer.ensure_systemd_unit(
"acmetool/acmetool-reconcile.timer",
dest_name="acmetool-reconcile.timer",
)
assert (
mock_put.call_args.kwargs["dest"]
== "/etc/systemd/system/acmetool-reconcile.timer"
)
def test_ensure_service():
with patch("cmdeploy.basedeploy.systemd.service") as mock_svc:
deployer = Deployer()
deployer.need_restart = True
deployer.daemon_reload = True
deployer.ensure_service("nginx.service")
mock_svc.assert_called_once_with(
name="Start and enable nginx.service",
service="nginx.service",
running=True,
enabled=True,
restarted=True,
daemon_reload=True,
)
# daemon_reload is cleared to avoid multiple systemctl daemon-reload calls
# need_restart is kept to ensure all subsequent services also restart
assert deployer.need_restart is True
assert deployer.daemon_reload is False
with patch("cmdeploy.basedeploy.systemd.service") as mock_svc:
# Stopping suppresses restarted even when need_restart is True
deployer = Deployer()
deployer.need_restart = True
deployer.daemon_reload = True
deployer.ensure_service(
"mta-sts-daemon.service",
running=False,
enabled=False,
)
assert mock_svc.call_args.kwargs["restarted"] is False
assert deployer.need_restart is True
with patch("cmdeploy.basedeploy.systemd.service") as mock_svc:
# Multiple calls: daemon_reload resets after first, need_restart persists
deployer = Deployer()
deployer.need_restart = True
deployer.daemon_reload = True
deployer.ensure_service("chatmaild.service")
deployer.ensure_service("chatmaild-metadata.service")
second_call = mock_svc.call_args_list[1]
assert second_call.kwargs["restarted"] is True
assert second_call.kwargs["daemon_reload"] is False

View File

@@ -39,6 +39,14 @@ class TestCmdline:
out, err = capsys.readouterr() out, err = capsys.readouterr()
assert "deleting config file" in out.lower() assert "deleting config file" in out.lower()
def test_dns_skip_on_ip(self, capsys, tmp_path, monkeypatch):
monkeypatch.delenv("CHATMAIL_INI", raising=False)
inipath = tmp_path / "chatmail.ini"
assert main(["init", "--config", str(inipath), "1.3.3.7"]) == 0
assert main(["dns", "--config", str(inipath)]) == 0
out, err = capsys.readouterr()
assert out == "[WARNING] 1.3.3.7 is not a domain, skipping DNS checks.\n"
def test_www_folder(example_config, tmp_path): def test_www_folder(example_config, tmp_path):
reporoot = importlib.resources.files(__package__).joinpath("../../../../").resolve() reporoot = importlib.resources.files(__package__).joinpath("../../../../").resolve()

View File

@@ -4,6 +4,7 @@ import pytest
from cmdeploy import remote from cmdeploy import remote
from cmdeploy.dns import check_full_zone, check_initial_remote_data, parse_zone_records from cmdeploy.dns import check_full_zone, check_initial_remote_data, parse_zone_records
from cmdeploy.remote.rdns import get_authoritative_ns
@pytest.fixture @pytest.fixture
@@ -14,11 +15,15 @@ def mockdns_base(monkeypatch):
if command.startswith("dig"): if command.startswith("dig"):
if command == "dig": if command == "dig":
return "." return "."
if "SOA" in command: if "with.public.soa" in command and "NS" in command:
return "domain.with.public.soa. 2419 IN NS ns1.first-ns.de."
if "with.hidden.soa" in command and "NS" in command:
return ( return (
"delta.chat. 21600 IN SOA ns1.first-ns.de. dns.hetzner.com." "domain.with.hidden.soa. 2137 IN NS ns1.desec.io.\n"
" 2025102800 14400 1800 604800 3600" "domain.with.hidden.soa. 2137 IN NS ns2.desec.org."
) )
if "NS" in command:
return "delta.chat. 21600 IN NS ns1.first-ns.de."
command_chunks = command.split() command_chunks = command.split()
domain, typ = command_chunks[4], command_chunks[6] domain, typ = command_chunks[4], command_chunks[6]
try: try:
@@ -125,6 +130,17 @@ class TestPerformInitialChecks:
assert not l assert not l
@pytest.mark.parametrize(
("domain", "ns"),
[
("domain.with.public.soa", "ns1.first-ns.de."),
("domain.with.hidden.soa", "ns1.desec.io."),
],
)
def test_get_authoritative_ns(domain, ns, mockdns):
assert get_authoritative_ns(domain) == ns
def test_parse_zone_records(): def test_parse_zone_records():
text = """ text = """
; This is a comment ; This is a comment

View File

@@ -23,8 +23,7 @@ def make_host(*fact_pairs):
if cls not in facts: if cls not in facts:
registered = ", ".join(c.__name__ for c in facts) registered = ", ".join(c.__name__ for c in facts)
raise LookupError( raise LookupError(
f"unexpected get_fact({cls.__name__}); " f"unexpected get_fact({cls.__name__}); only registered: {registered}"
f"only registered: {registered}"
) )
return facts[cls] return facts[cls]

View File

@@ -1,11 +1,10 @@
import importlib.resources from pathlib import Path
from cmdeploy.www import build_webpages from cmdeploy.www import build_webpages
def test_build_webpages(tmp_path, make_config): def test_build_webpages(tmp_path, make_config):
pkgroot = importlib.resources.files("cmdeploy") src_dir = (Path(__file__).resolve() / "../../../../../www/src").resolve()
src_dir = pkgroot.joinpath("../../../www/src").resolve()
assert src_dir.exists(), src_dir assert src_dir.exists(), src_dir
config = make_config("chat.example.org") config = make_config("chat.example.org")
build_dir = tmp_path.joinpath("build") build_dir = tmp_path.joinpath("build")

View File

@@ -1,5 +1,4 @@
import hashlib import hashlib
import importlib.resources
import re import re
import time import time
import traceback import traceback
@@ -37,7 +36,7 @@ def prepare_template(source):
def get_paths(config) -> (Path, Path, Path): def get_paths(config) -> (Path, Path, Path):
reporoot = importlib.resources.files(__package__).joinpath("../../../").resolve() reporoot = (Path(__file__).resolve() / "../../../../").resolve()
www_path = Path(config.www_folder) www_path = Path(config.www_folder)
# if www_folder was not set, use default directory # if www_folder was not set, use default directory
if config.www_folder == "": if config.www_folder == "":
@@ -133,8 +132,7 @@ def find_merge_conflict(src_dir) -> Path:
def main(): def main():
path = importlib.resources.files(__package__) reporoot = (Path(__file__).resolve() / "../../../../").resolve()
reporoot = path.joinpath("../../../").resolve()
inipath = reporoot.joinpath("chatmail.ini") inipath = reporoot.joinpath("chatmail.ini")
config = read_config(inipath) config = read_config(inipath)
config.webdev = True config.webdev = True

View File

@@ -15,6 +15,7 @@ goes beyond what classic email servers offer:
streaming, privacy-preserving Push Notifications for Apple, Google, and `Ubuntu Touch <https://docs.ubports.com/en/latest/appdev/guides/pushnotifications.html>`_; streaming, privacy-preserving Push Notifications for Apple, Google, and `Ubuntu Touch <https://docs.ubports.com/en/latest/appdev/guides/pushnotifications.html>`_;
- **Security Enforcement**: only strict TLS, DKIM and OpenPGP with minimized metadata accepted - **Security Enforcement**: only strict TLS, DKIM and OpenPGP with minimized metadata accepted
(DKIM is not enforced on :ref:`IP-only relays <iponly>`)
- **Reliable Federation and Decentralization:** No spam or IP reputation checks, federating - **Reliable Federation and Decentralization:** No spam or IP reputation checks, federating
depends on established IETF standards and protocols. depends on established IETF standards and protocols.
@@ -47,6 +48,28 @@ Dovecot, and are configured to run unattended without much maintenance
effort. Chatmail relays happily run on low-end hardware like a Raspberry effort. Chatmail relays happily run on low-end hardware like a Raspberry
Pi. Pi.
.. _upgrade:
How can I upgrade my chatmail relay?
------------------------------------
To upgrade to the latest ``main`` branch,
``cd`` into your local checkout of `https://github.com/chatmail/relay/`_
and run the following commands:
::
git pull origin main --rebase --autostash
scripts/cmdeploy run
If you don't want the latest development version,
but a specific tagged release like `1.10.0 <https://github.com/chatmail/relay/releases/tag/1.10.0>`_,
run ``git pull origin 1.10.0`` instead.
If you made local changes for your setup,
they will be reapplied as long as they don't conflict with the upgrade.
If a conflict arises, ``git status`` will tell you how to resolve it.
How trustable are chatmail relays? How trustable are chatmail relays?
---------------------------------- ----------------------------------

View File

@@ -14,8 +14,6 @@ Minimal requirements and prerequisites
You will need the following: You will need the following:
- Control over a domain through a DNS provider of your choice.
- A Debian 12 **deployment server** with reachable SMTP/SUBMISSIONS/IMAPS/HTTPS ports. - A Debian 12 **deployment server** with reachable SMTP/SUBMISSIONS/IMAPS/HTTPS ports.
IPv6 is encouraged if available. Chatmail relay servers only require IPv6 is encouraged if available. Chatmail relay servers only require
1GB RAM, one CPU, and perhaps 10GB storage for a few thousand active 1GB RAM, one CPU, and perhaps 10GB storage for a few thousand active
@@ -28,6 +26,11 @@ You will need the following:
(An ed25519 private key is required due to an `upstream bug in (An ed25519 private key is required due to an `upstream bug in
paramiko <https://github.com/paramiko/paramiko/issues/2191>`_) paramiko <https://github.com/paramiko/paramiko/issues/2191>`_)
- Control over a domain through a DNS provider of your choice
(there is experimental support for :ref:`IP-only relays <iponly>`).
.. _setup:
Setup with ``scripts/cmdeploy`` Setup with ``scripts/cmdeploy``
------------------------------------- -------------------------------------
@@ -98,6 +101,15 @@ steps. Please substitute it with your own domain.
configure at your DNS provider (it can take some time until they are configure at your DNS provider (it can take some time until they are
public). public).
Docker installation
-------------------
There is experimental support for running chatmail via Docker.
A monolithic image based on the above cmdeploy method is available `through a separate repository <https://github.com/chatmail/docker/pkgs/container/docker>`_.
See the `chatmail/docker README <https://github.com/chatmail/docker>`_ for full setup instructions.
Other helpful commands Other helpful commands
---------------------- ----------------------

View File

@@ -19,3 +19,4 @@ Contributions and feedback welcome through the https://github.com/chatmail/relay
reverse_dns reverse_dns
related related
faq faq
iponly

40
doc/source/iponly.rst Normal file
View File

@@ -0,0 +1,40 @@
.. _iponly:
Hosting without DNS records
===========================
.. note::
This option is experimental and might change without notice.
In case you don't have a domain,
for example in a local network,
you can run a chatmail relay with only an IPv4 address as well.
To deploy a relay without a domain,
run ``cmdeploy init`` with only the IPv4 address
during the :ref:`installation steps <setup>`,
for example ``cmdeploy init 13.12.23.42``.
Drawbacks
---------
- your transport encryption will only use self-signed TLS certificates,
which are vulnerable against MITM attacks.
the chatmail core's end-to-end encryption should suffice in most scenarios though.
- your messages will not be DKIM-signed;
experimentally, most chatmail relays accept non-DKIM-signed messages from IP-only relays,
but some relays might not accept messages from yours.
Email addresses
---------------
When running without a domain,
your chatmail addresses will use the IPv4 address
in brackets as the domain part,
for example ``user@[13.12.23.42]``.
This is a valid email address format
according to :rfc:`5321`.

View File

@@ -153,6 +153,7 @@ Chatmail relay dependency diagram
autoconfig.xml --- dovecot; autoconfig.xml --- dovecot;
postfix --- |10080|filtermail-outgoing; postfix --- |10080|filtermail-outgoing;
postfix --- |10081|filtermail-incoming; postfix --- |10081|filtermail-incoming;
postfix --- |10083|filtermail-transport;
filtermail-outgoing --- |10025 reinject|postfix; filtermail-outgoing --- |10025 reinject|postfix;
filtermail-incoming --- |10026 reinject|postfix; filtermail-incoming --- |10026 reinject|postfix;
dovecot --- |doveauth.socket|doveauth; dovecot --- |doveauth.socket|doveauth;
@@ -264,7 +265,8 @@ from the chatmail relay server.
Email domain authentication (DKIM) Email domain authentication (DKIM)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Chatmail relays enforce :rfc:`DKIM <6376>` to authenticate incoming emails. Chatmail relays enforce :rfc:`DKIM <6376>` to authenticate incoming emails
(except for :ref:`IP-only relays <iponly>`).
Incoming emails must have a valid DKIM signature with Incoming emails must have a valid DKIM signature with
Signing Domain Identifier (SDID, ``d=`` parameter in the DKIM-Signature Signing Domain Identifier (SDID, ``d=`` parameter in the DKIM-Signature
header) equal to the ``From:`` header domain. This property is checked header) equal to the ``From:`` header domain. This property is checked
@@ -295,9 +297,7 @@ ensured by ``filtermail`` proxy.
TLS requirements TLS requirements
~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~
Postfix is configured to require valid TLS by setting Filtermail (used for delivery) requires a valid TLS.
`smtp_tls_security_level <https://www.postfix.org/postconf.5.html#smtp_tls_security_level>`_
to ``verify``.
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