Compare commits

...
Author SHA1 Message Date
j4n 361cc64583 dovecot: security backports, new versioning scheme
- Fix dovecot package download URLs for new
  [release](https://github.com/chatmail/dovecot/releases/tag/upstream%2F2.3.21%2Bdfsg1-3%2Bchatmail2)
  with
    - debian-security backport for 12 CVEs
    - distro-specific suffix (+deb{release}u1), enabling a simplified
      primary URL path and combined github releases
- Use VERSION_ID from os-release as deb_release instead of codename
  mapping, reorder hash-dict to match Github release page
- Remove redundant parsing/validation, let function validate against hash dict
- Update test expectations and test new versioning derivation
2026-09-02 12:07:49 +02:00
j4n 66c6a77b3a fix(cmdeploy): check venv python versions and purge if mismatched
`cmdeploy run` fails after system upgrade to Debian 13 with "Fatal Python
error: init_fs_encoding: failed to get the Python codec of the filesystem
encoding" indicating a Python version missmatch. Check for both versions and
remove old `remote_venv_dir` on mismatch to allow clean reinitialization by
subsequent pip.virtualenv().
2026-09-02 12:04:28 +02:00
j4n 284fe5c422 ci: temporarily build docker packages for bookworm branch 2026-09-02 10:42:14 +02:00
j4n b569ae69e4 dovecot: add multi-dist/Debian trixie support
- Install .debs via apt-get install instead of dpkg+fix-broken
- Package hashes are now keyed by (arch, codename, pkg):
  - download.delta.chat uploads now go to dovecot/{distro}/{version}/
  - GitHub release packages get a _{distro}.deb suffix to allow for
    combined releases.

Tests:
- updated to support this and add a test to check for the unsupported
  release version case
- fix make_host to accept extra args from Command fact
- assert single apt-get install command
2026-09-02 10:42:11 +02:00
j4n 9a5b17cbfc dovecot: pin dovecot-* to priority -1 before any apt operation
Prevent Trixie from somehow pulling in dovecot 2.4 before we get to install.
2026-09-02 10:40:42 +02:00
holger krekel 2eb0ef5f9e feat: distinguish AUTHENTICATION_FAILED/UNAVAILABLE login failures
on doveauth.py being down, users would "AUTHENTIFICATIONFAILED"
while they now get:

    IMAP failed to login as tovnlbmsz@_lua0.localchat: no response:
    code: None, info: Some("[UNAVAILABLE] Temporary authentication
    failure. [lua0-localchat:2026-09-01 11:00:17]")
2026-09-01 22:56:07 +02:00
holger krekel 2d0fc2e70e feat: move doveauth from dictproxy to lua/http
1. existing logins are now verified by lua only

2. non-existing logins are delegated to the new Python doveauth http /create endpoint

Using Lua and http this way makes doveauth more compatible to dovecot 2.4
2026-09-01 22:56:07 +02:00
holger krekel 051f831518 test!: remove global registration of pytest plugins
Instead a conftest.py close to the test files needs
to opt into using plugin hooks and fixtures.

also remove some packaging leftover with initenv.sh
2026-08-26 19:22:39 +02:00
holger krekel fa24dd17de test: cleanup and allow a repo-root level "pytest -n6" to succeed.
the fixtures from chatmaild and cmdeploy test plugins were clashing,
and the "rpc" fixture was shadowed by deltachat-rpc-client.
We could change the way plugins load but it's also useful to have
disambiguated fixture names as we are often working across the code bases.

also removes a few unused historic fluff.
2026-08-26 19:22:39 +02:00
holger krekel ae6b89f936 fix: actually use UTC time instead of just seemingly using it
datetime.now(timezone.utc).timestamp() is actually 7200 seconds in the past.
The Python deprecation warning of utcnow() warning actually pointed there.
2026-08-26 19:21:56 +02:00
holger krekel d25e8a8ee8 chore(doc): use sphinx roles for referencing repository files and dirs
this allows PRs to add references without having to point to main where
a file might not be, and thus fail CI.
2026-08-26 15:32:16 +02:00
j4n cae03e2714 chore(cmdeploy): refactor all pins into pins.py 2026-08-26 15:10:34 +02:00
j4n 7db16cc716 feat(mtail): validate programs during deploy
Compile /etc/mtail before activate() restarts the service, to catch errors
early.
2026-08-26 13:19:54 +02:00
j4n 4cdccee63b feat(mtail): deploy filtermail.mtail and gate mtail rule copy on mtail_address
Deploy filtermail.mtail program along delivered_mail.mtail, fetched from
upstream; for this, refactor download_executable to accept mode, so we
can use it to upload non-binaries.
refactor: make hashes (for uniformity) and mtail version (for use by
mtail deployer) module constants.

Additionally, gate both mtail programs on mtail_address being set.
2026-08-26 13:19:54 +02:00
holger krekel 12664d9188 chore: follow the new mtail release source, upgrade 3.0.8 to 3.4.9
see https://github.com/google/mtail/issues/929 for the move.
2026-08-25 19:36:20 +02:00
holger krekel 1f0ddb7e5b chore: un-hardcode executable paths in some systemd service files
makes it consistent with the other services.
2026-08-25 17:30:32 +02:00
holger krekel 2af8d0e7b5 test: integrate lua testing into regular pytest run for push notifications
turns out Python has the nice https://pypi.org/project/lupa/
that allows us to quite easily test dovecot LUA parts
without figuring out errors on deploy.
2026-08-25 11:38:17 +02:00
holger krekel e489a1ea29 ci: try to fix lack of RFC822 item support in madmail and make CI pass
some local debugging revealed madmail v2 does not support fetching RFC822 items
(gives an empty body) so try BODY.PEEK[] instead.
2026-08-24 18:27:14 +02:00
adbenitezandholger krekel 455da45d36 update gplay to 2.59.1 2026-08-24 17:51:05 +02:00
KamyarATandholger krekel 48ad92bf24 docs: describe Madmail v2 as a Rust chatmail relay
The previous blurb still called Madmail an experimental Maddy fork.
That applied to v1. v2 is a Rust rewrite that ships SMTP, IMAP,
encryption enforcement, and real-time services in a single binary.
2026-08-20 15:36:27 +02:00
holger krekel 33f9cddb1b feat: serve an APPVERSIONS.json index file to clients via IMAP metadata
This is designed to help implement self-updating APKs (and later other clients),
see counterpart https://github.com/chatmail/core/pull/8557
2026-08-13 12:54:47 +02:00
missytakeandGitHub dc8e0a34a2 chore(release): prepare for 1.12.0 (#1034) 2026-07-31 11:48:16 +02:00
holger krekel efc24fcdf3 cleanup: contents:read not needed for relay repo
public repos need no contents::read and there were permissions: {}
2026-07-30 20:44:04 +02:00
missytake 9a9bda80b1 fix: ss -tulpn can sometimes show dovecot first 2026-07-30 12:12:27 +02:00
missytake 74f4721f2b ci: fix docs upload path 2026-07-30 09:38:41 +02:00
holger krekel 14f829003d fix: List Iroh proxy endpoints used by 0.35 and 1.0, drop stale /relay/probe from earlier versions 2026-07-29 22:44:21 +02:00
link2xt 5da9aaeb37 refactor: install dns-root-data instead of using unbound-anchor
unbound-anchor retrieves root trust anchor directly via HTTPS,
but Debian already distributes the same files in dns-root-data package.
2026-07-29 19:28:06 +00:00
holger krekel d068052f0c restrict usable workflow to read permissions 2026-07-29 14:15:35 +02:00
holger krekel fa5afadaaa feat!: introduce configurable system limits to reject new address creation and limit imap/smtp connections.
The default values are geared towards minimal-requirements server.
If you have a big server, you will need to set chatmail.ini parameters.
2026-07-28 19:08:19 +02:00
j4n eb862f1645 chore(ci): update doc staging upload path 2026-07-27 17:38:50 +02:00
holger krekel 6f3039509c feat(dovecot): advertise privacy_mail as admin contact, drop server comment
- The hardcoded root@relaydomain address was unusued/unrooted
  and no one knew about it, it was used nowhere.
  Better to use privacy_mail which operators can set
  which might eventually be shown in the UI's relay list.

- remove generic useless mail_server_comment string.
  Clients receive these values as IMAP METADATA /shared/admin and
  /shared/comment only for internal debugging/info purposes.
2026-07-26 14:13:08 +02:00
link2xt 504fb249a9 refactor(postfix): remove unused "filter" lmtp service
It looks like the reason it exists in the default configuration
is an example for <https://www.postfix.org/FILTER_README.html>
Using this filter requires configuration such as
"-o content_filter=filter:dummy" and we don't have it.
2026-07-22 20:28:36 +00:00
link2xt deca1ef2a3 feat(postfix): disable processing of MIME headers
Default behavior is processing headers
that are actually part of the message body.
This is unlikely to be needed
as headers such as Received are not added into MIME parts,
but may result in broken DKIM signatures
if the body is modified.
2026-07-13 09:46:32 +00:00
W0rmsyandGitHub 0323e757a9 chore: update Let's Encrypt Subscriber Agreement to 1.8
The changes are described in the announcement:
https://community.letsencrypt.org/t/updating-the-let-s-encrypt-subscriber-agreement-to-v1-8/248355
2026-07-13 09:45:41 +00:00
j4n 752957dae8 feat(mtail): add incoming_mailer_daemon_mail_count
Track volume of mailer-daemon messages separately from unencrypted.
2026-07-13 11:43:44 +02:00
Jagoda Estera ŚlązakandGitHub 3a553f2286 chore(deps): Upgrade filtermail to v0.7.4 (#1014)
## 0.7.4 - 2026-07-01

### Features

- *(logs)* Log incoming mailer-daemon message sources

### Miscellaneous Tasks

- Update filtermail.mtail

### Testing

- Place #[tokio::test] after rstest case macros

## 0.7.3 - 2026-06-27

### Features

- *(transport)* Worker eviction

## 0.7.2 - 2026-06-26

### Documentation

- *(readme)* Disable colors in mermaid diagrams
- *(readme)* Update Transport mode doc

### Features

- *(transport)* Destination worker pool

### Refactor

- Implement Display for AddressDomain

### Testing

- Test filtermail-transport

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-07-04 15:01:06 +09:00
HocuriandGitHub cb1e4ff5bb docs(README.md): Clarify security enforcement (#1011)
Make it clear which security enforcement properties a chatmail server has
2026-06-17 12:38:37 +02:00
Jagoda Ślązakandmissytake 8d7870db55 chore(deps): Upgrade filtermail to v0.7.1
## 0.7.1 - 2026-06-09

### Bug Fixes

- Switch ratelimiter to MonotonicClock
- *(smtp-server)* Correct error when EOF while reading DATA
- *(resolver)* Disable negative caching
- Ignore CNAME records when resolving TXT records

### Features

- Switch to aws-lc-rs cryptography provider

### Miscellaneous Tasks

- Add filtermail.mtail so filtermail failures can be monitored

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-06-17 10:16:35 +02:00
Jagoda Estera ŚlązakandGitHub 5a31ff475f docs(overview): Remove mermaid styles from 'Accepting and delivering mail' (#1009)
Previously used colors caused
diagrams to be unreadable in
light mode.
Removing styling completely,
as this was redundant anyway.

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-06-10 22:31:26 +02:00
missytakeandj4n 8c18aea18e fix: pass kwargs to files.put() 2026-06-10 16:49:34 +02:00
missytakeandj4n ebf5a51964 fix: still overwrite /etc/resolv.conf if it is a symbolic link 2026-06-10 16:49:34 +02:00
feldandGitHub f596d4b56d Merge pull request #1003 from chatmail/feld/crypt-r
fix: crypt-r dependency was declared for wrong Python version
2026-06-09 12:38:45 -07:00
Mark Felder 8e3c18019b fix: crypt-r dependency was declared for wrong Python version
The original crypt library was last supported in Python 3.12, so it's
not needed until Python 3.13 is default (Trixie)

> The crypt_r module is a renamed copy of the crypt module as it was present in Python 3.12 before it was removed.
2026-06-09 12:38:07 -07:00
missytake 9da3f5c235 fix(acmetool): update let's encrypt ToS link 2026-06-08 16:28:21 +02:00
feldandmissytake 6def189d16 Revert "Aggressive LMTP header cleanup (#816)"
This reverts commit 921080125f.
2026-06-05 22:28:16 +02:00
Mark Felderandmissytake 24612e9121 fix(deps): Remove domain-validator dependency
It is broken in multiple ways
2026-06-05 09:39:27 +02:00
a9dd9fe3e0 docs: Update overview diagrams (#995)
Adds a detailed diagram describing
all paths a message can take,
that takes into account postfix services.

Additionally, adds OpenDKIM to dependency
diagram.

Fixes: #771
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
Co-authored-by: missytake <missytake@systemli.org>
2026-06-03 12:24:32 +02:00
aa846c3478 fix: expire empty directories (#994)
* fix: respect --dry when expiring empty directories

Co-authored-by: j4n <j4n@systemli.org>
2026-06-03 10:42:28 +02:00
921080125f Aggressive LMTP header cleanup (#816)
This will remove all headers possible during LMTP delivery, except:

- From: required or core does not process the message correctly.
  Also required for cleartext compatibility.
- Message-Id: required for clients to know which messages have been
  downloaded
- Chat-Is-Post-Message: is required for our attachment previews
- Content-Type: required
- For Cleartext compability: To, CC, In-Reply-To, References, Subject,
  and Date
- For Chatmail future expansion, allow Chat-*
- Permit the entire Secure-Join* namespace

Co-authored-by: holger krekel  <holger@merlinux.eu>
2026-06-03 08:43:36 +02:00
Jagoda Estera ŚlązakandGitHub d898f41064 fix: Always deploy unbound.conf.d/chatmail.conf (#993)
This fixes issue with negative cache
only disabled in ipv4-only mode.

Follow up to #992

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-06-02 12:02:07 +02:00
Jagoda Estera ŚlązakandGitHub e9e012234b feat: Disable negative cache in unbound (#992)
Related:
- https://github.com/chatmail/relay/issues/543
- https://github.com/chatmail/filtermail/pull/170

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-06-02 10:48:28 +02:00
Jagoda Estera ŚlązakandGitHub bb40c5bb21 fix: Check if all required ports are available for filtermail (#983)
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-06-01 11:10:25 +02:00
Jagoda Estera ŚlązakandGitHub a229f1bc45 chore(deps): Upgrade filtermail to v0.7 (#982)
## 0.7.0 - 2026-05-26

### Bug Fixes

- Do not crash if accepting new connection fails

### Documentation

- *(readme)* Remove docs for options removed in da9a116

### Features

- [**breaking**] Remove passthrough options that allowed unencrypted mail to pass

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-06-01 09:55:50 +02:00
link2xt 4ba19b0031 test: set socket security for IMAP and SMTP to "TLS" in "dclogin"
With "default" (like it was for SMTP) or not set (like it was for IMAP),
both TLS and STARTTLS are tried.
Trying STARTTLS against TLS port is going to timeout
because in STARTTLS server talks first,
but when connected to TLS port the server
waits for TLS client hello and does not send anything.

Should not actually matter in tests which connect successfully
on the first try because implicit TLS is tried first.
2026-05-28 22:29:08 +00:00
holger krekel 5eab3a5a25 try using cmlxc main branch fix for delete-server issue 2026-05-28 21:40:05 +02:00
holger krekel 30729d9be0 fix: core 2.50.0 does not have delete_server_after config anymore. 2026-05-28 21:40:05 +02:00
link2xt 4b04aae83b feat: reduce maximal_queue_lifetime from 5d to 2d
If the message is not delivered within 2 days,
it is unlikely to be delivered in 5 days either.
2026-05-20 19:27:58 +00:00
link2xt 0eed92171c fix: reduce maxproc for filtermail-transport LMTP client to 500
This further reduces it from 1000.
For small servers this may be needed if they have low memory.
For large servers may be increased manually for now.
2026-05-20 15:39:11 +00:00
link2xt a5b9a98baa fix: limit the number of LMTP clients for filtermail-transport to 1000
Postfix does not have jitter for deferred mails
and scans the queue periodically every
queue_run_delay (<https://www.postfix.org/postconf.5.html#queue_run_delay>).
As a result it is likely
to try delivering many deferred messages
at the same time.

Normally the number of outgoing connections
should be low even with unreachable destinations,
but after the server downtime
or if admin flushes the queue manually
it is possible that a lot of messages
to the same unreachable destination
expire at once and are moved
from "deferred" into the "active" queue.

Trying to deliver them all at once
may make the server run out of memory
by starting many LMTP clients.
Limiting the number of LMTP processes
turns OOM problem into head of line blocking problem.
Messages sent to reachable destinations
will be delayed as well,
but at least deferred messages will
get distributed over time.

In this case "active" queue may grow
(up to qmgr_message_active_limit defaulting to 20000),
but then admin may notice the problem
and solve it e.g. by making the destinations reachable
or setting up a transport map to route
messages for known dead servers into discard transport.

Eventually the problem should be solved
by filtermail-transport quickly returning temporary errors
for destinations which already have many messages queued,
then we can reduce "maxproc" further.
2026-05-19 22:17:04 +00:00
link2xt ab2d807084 fix: set relay restrictions per smtpd service with default reject
We never want to defer email with a tepporary error when it has destination
that we cannot deliver locally and don't want to relay.
To avoid doing this accidentally, set default action to "reject"
and then override it with the minimal restrictions per smtpd.

Submission ports already had smtpd_relay_restrictions=permit_sasl_authenticated,reject override.

Each smtpd port must have at least one of
reject, reject_unauth_destination, defer, defer_if_permit, defer_unauth_destination
according to <https://www.postfix.org/postconf.5.html#smtpd_relay_restrictions>.

I have set smtpd_relay_restrictions=reject_unauth_destination for port 25 and incoming reinject port,
and smtpd_relay_restrictions=permit_mynetworks,reject for outgoing reinject port.
2026-05-19 15:54:15 +00:00
j4n ce05b26c77 ci: auto-trigger docker build on release tag push
docker-dispatch.yaml previously only fired on push to main and manual
workflow_dispatch, so tagging 1.11.0 did not build the release image.
This change adds matching of X.Y.Z tag.
2026-05-19 14:58:05 +02:00
missytake 77ed93fb7a docs: add scripts/initenv.sh to upgrade instructions 2026-05-18 10:35:25 +02:00
missytake 39d1ecaa03 chore(release): prepare for 1.11.0 2026-05-15 17:13:58 +02:00
holger krekel a266ffd060 fix: fix #972 by increasing file descriptors for filtermail 2026-05-14 22:40:25 +02:00
holger krekel a47bb94143 feat: warn about any unused chatmail.ini parameter at the end of "cmdeploy run" 2026-05-14 20:58:47 +02:00
holger krekel 43ae9fee5c feat!: ignore passthrough_sender and passthrough_recipients to eliminate one more source of unencrypted messages
When running "cmdeploy run" operators will see a warning if their chatmail.ini contains these unused options.
2026-05-14 20:58:47 +02:00
holger krekel 42dc781d7d feat: make turn_socket_path configurable, and cleanup tests and turnserver code.
this is originally motivated by https://github.com/chatmail/relay/pull/840
2026-05-13 21:02:28 +02:00
missytakeandGitHub 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
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ązakandGitHub 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ązakandGitHub c0b207c320 chore(deps): Upgrade filtermail to v0.6.5 (#966) 2026-05-12 10:37:28 +02:00
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
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-organdmissytake 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ązakandGitHub b05e26819f fix: Increase concurrency limit and re-enable filtermail-transport (#949) 2026-05-05 18:30:20 +02:00
Jagoda Estera ŚlązakandGitHub 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
16b00da373 chore: prepare 1.10.0 release (#943)
Co-authored-by: j4n <j4n@systemli.org>
2026-04-30 15:51:17 +02:00
j4n 75606f5eb8 fix(mtail): start after networking is fully up 2026-04-30 14:23:32 +02:00
holger krekel d256538f81 testing: support custom filtermail binary through CHATMAIL_FILTERMAIL_BINARY env var 2026-04-29 20:27:12 +02:00
link2xt fdf8e5e345 ci: setup zizmor
Zizmor is a linter for GitHub Actions
2026-04-29 16:58:19 +00:00
j4n 81a161d433 feat(ci): add repository_dispatch trigger to chatmail/docker
On push to main send a repository_dispatch event to chatmail/docker with
relay_ref, relay_sha, and relay_sha_short.

This triggers docker-ci.yaml to build a new Docker image from
the updated relay code, push to GHCR, and eventually run integration
tests via cmlxc's reusable lxc-test workflow.

Requires DOCKER_DISPATCH_TOKEN secret with repo scope on
chatmail/docker.

Also set workflow_dispatch to allow manual triggering of Docker builds
from any relay branch via the GitHub UI.
2026-04-29 15:43:19 +02:00
100 changed files with 3060 additions and 1541 deletions
+40
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@main
with:
cmlxc_version: main
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
+9 -3
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
@@ -9,6 +9,8 @@ on:
pull_request: pull_request:
branches: [ "main" ] branches: [ "main" ]
permissions: {}
# Newest push wins: Prevents multiple runs from clashing and wasting runner efforts # Newest push wins: Prevents multiple runs from clashing and wasting runner efforts
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
@@ -25,8 +27,9 @@ jobs:
# Otherwise `test_deployed_state` will be unhappy. # Otherwise `test_deployed_state` will be unhappy.
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
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.7.4/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
@@ -38,6 +41,7 @@ jobs:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
- name: initenv - name: initenv
run: scripts/initenv.sh run: scripts/initenv.sh
@@ -53,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@main
with: with:
cmlxc_version: main
cmlxc_commands: | cmlxc_commands: |
cmlxc init cmlxc init
# single cmdeploy relay test # single cmdeploy relay test
@@ -71,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
+38
View File
@@ -0,0 +1,38 @@
# Notify the docker repo to build and test a new image after relay CI passes.
#
# Sends a repository_dispatch event to chatmail/docker with the relay ref
# and short SHA, which triggers docker-ci.yaml to build, push to GHCR,
# and run integration tests via cmlxc.
name: Trigger Docker build
on:
push:
branches: [main, j4n/dovecot-multidist]
tags: ['[0-9]+.[0-9]+.[0-9]+']
workflow_dispatch:
permissions: {}
jobs:
dispatch:
name: Dispatch build to chatmail/docker
runs-on: ubuntu-latest
if: github.repository == 'chatmail/relay'
steps:
- name: Compute short SHA
id: sha
run: echo "short=$(echo '${{ github.sha }}' | cut -c1-7)" >> "$GITHUB_OUTPUT"
- name: Send repository_dispatch
uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3
with:
token: ${{ secrets.CHATMAIL_DOCKER_DISPATCH_TOKEN }}
repository: chatmail/docker
event-type: relay-updated
client-payload: >-
{
"relay_ref": "${{ github.ref_name }}",
"relay_sha": "${{ github.sha }}",
"relay_sha_short": "${{ steps.sha.outputs.short }}"
}
+16 -3
View File
@@ -7,15 +7,24 @@ on:
- 'scripts/build-docs.sh' - 'scripts/build-docs.sh'
- '.github/workflows/docs-preview.yaml' - '.github/workflows/docs-preview.yaml'
permissions: {}
jobs: jobs:
scripts: scripts:
name: build name: build
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
# Pin the repository links in the docs to this pull request's head commit
# so that linkcheck resolves files which only exist on the branch so far.
# see doc/conf.py
DOC_GITHUB_REF: ${{ github.event.pull_request.head.sha }}
environment: environment:
name: 'staging.chatmail.at/doc/relay/' name: 'staging.chatmail.at/doc/relay/'
url: https://staging.chatmail.at/doc/relay/${{ steps.prepare.outputs.prid }} url: https://staging.chatmail.at/doc/relay/${{ steps.prepare.outputs.prid }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
persist-credentials: false
- name: initenv - name: initenv
run: scripts/initenv.sh run: scripts/initenv.sh
@@ -34,18 +43,22 @@ jobs:
- name: Get Pullrequest ID - name: Get Pullrequest ID
id: prepare id: prepare
run: | run: |
export PULLREQUEST_ID=$(echo "${{ github.ref }}" | cut -d "/" -f3) export PULLREQUEST_ID=$(echo "${GITHUB_REF}" | cut -d "/" -f3)
echo "prid=$PULLREQUEST_ID" >> $GITHUB_OUTPUT echo "prid=$PULLREQUEST_ID" >> $GITHUB_OUTPUT
if [ $(expr length "${{ secrets.USERNAME }}") -gt "1" ]; then echo "uploadtoserver=true" >> $GITHUB_OUTPUT; fi if [ $(expr length "${{ secrets.USERNAME }}") -gt "1" ]; then echo "uploadtoserver=true" >> $GITHUB_OUTPUT; fi
- run: | - run: |
echo "baseurl: /${{ steps.prepare.outputs.prid }}" >> _config.yml echo "baseurl: /${STEPS_PREPARE_OUTPUTS_PRID}" >> _config.yml
env:
STEPS_PREPARE_OUTPUTS_PRID: ${{ steps.prepare.outputs.prid }}
- name: Upload preview - name: Upload preview
run: | run: |
mkdir -p "$HOME/.ssh" mkdir -p "$HOME/.ssh"
echo "${{ secrets.CHATMAIL_STAGING_SSHKEY }}" > "$HOME/.ssh/key" echo "${{ secrets.CHATMAIL_STAGING_SSHKEY }}" > "$HOME/.ssh/key"
chmod 600 "$HOME/.ssh/key" chmod 600 "$HOME/.ssh/key"
rsync -rILvh -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/doc/build/ "${{ secrets.USERNAME }}@chatmail.at:/var/www/html/staging.chatmail.at/doc/relay/${{ steps.prepare.outputs.prid }}/" rsync -rILvh -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/doc/build/ "${{ secrets.USERNAME }}@chatmail.at:${STEPS_PREPARE_OUTPUTS_PRID}/"
env:
STEPS_PREPARE_OUTPUTS_PRID: ${{ steps.prepare.outputs.prid }}
- name: check links - name: check links
working-directory: doc working-directory: doc
+5 -1
View File
@@ -10,6 +10,8 @@ on:
- 'scripts/build-docs.sh' - 'scripts/build-docs.sh'
- '.github/workflows/docs.yaml' - '.github/workflows/docs.yaml'
permissions: {}
jobs: jobs:
scripts: scripts:
name: build name: build
@@ -19,6 +21,8 @@ jobs:
url: https://chatmail.at/doc/relay/ url: https://chatmail.at/doc/relay/
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
persist-credentials: false
- name: initenv - name: initenv
run: scripts/initenv.sh run: scripts/initenv.sh
@@ -43,5 +47,5 @@ jobs:
mkdir -p "$HOME/.ssh" mkdir -p "$HOME/.ssh"
echo "${{ secrets.CHATMAIL_STAGING_SSHKEY }}" > "$HOME/.ssh/key" echo "${{ secrets.CHATMAIL_STAGING_SSHKEY }}" > "$HOME/.ssh/key"
chmod 600 "$HOME/.ssh/key" chmod 600 "$HOME/.ssh/key"
rsync -rILvh -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/doc/build/ "${{ secrets.USERNAME }}@chatmail.at:/var/www/html/chatmail.at/doc/relay/" rsync -rILvh -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/doc/build/ "${{ secrets.USERNAME }}@chatmail.at:"
+26
View File
@@ -0,0 +1,26 @@
name: GitHub Actions Security Analysis with zizmor
on:
push:
branches: ["main"]
pull_request:
branches: ["**"]
permissions: {}
jobs:
zizmor:
name: Run zizmor
runs-on: ubuntu-latest
permissions:
security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files.
contents: read
actions: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Run zizmor
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
+7
View File
@@ -0,0 +1,7 @@
rules:
unpinned-uses:
config:
policies:
actions/*: ref-pin
dependabot/*: ref-pin
chatmail/*: ref-pin
+181
View File
@@ -1,5 +1,186 @@
# Changelog for chatmail deployment # Changelog for chatmail deployment
## [1.12.0] - 2026-07-31
### Breaking Changes
- [**breaking**] Introduce configurable system limits to reject new address creation and limit imap/smtp connections.
Dovecot default connection limit lowered from 50k to 10k,
Postfix default connection limit lowered from 5k to 1k,
larger relays need to adjust their settings.
### Features
- Reduce maximal_queue_lifetime from 5d to 2d
- Disable negative cache in unbound (#992)
- *(mtail)* Add incoming_mailer_daemon_mail_count
- *(postfix)* Disable processing of MIME headers
- *(dovecot)* Advertise privacy_mail as admin contact, drop server comment
### Bug Fixes
- Set relay restrictions per smtpd service with default reject
- Reduce maxproc for filtermail-transport LMTP client to 500
- Core 2.50.0 does not have delete_server_after config anymore.
- Check if all required ports are available for filtermail (#983)
- Always deploy unbound.conf.d/chatmail.conf (#993)
- Expire empty directories (#994)
- Crypt-r dependency was declared for wrong Python version
- Always overwrite /etc/resolv.conf, even if it is a symbolic link
- Pass kwargs to files.put()
- List Iroh proxy endpoints used by 0.35 and 1.0, drop stale /relay/probe from earlier versions
- Fix port discovery when ss -tulpn shows dovecot before stats
### Documentation
- Add scripts/initenv.sh to upgrade instructions
- Update overview diagrams (#995)
- *(overview)* Remove mermaid styles from 'Accepting and delivering mail' (#1009)
- *(README.md)* Clarify security enforcement (#1011)
### Miscellaneous Tasks
- *(ci)* Auto-trigger docker build on release tag push
- *(acmetool)* Update let's encrypt ToS link to 1.8
- *(ci)* Update doc staging upload path
- *(ci)* Fix docs upload path
### Refactor
- *(postfix)* Remove unused "filter" lmtp service
- Install dns-root-data instead of using unbound-anchor
- *(deps)* Remove domain-validator dependency
### Testing
- Set socket security for IMAP and SMTP to "TLS" in "dclogin"
## [1.11.0] - 2026-05-15
### Breaking Changes
- [**breaking**] Drop passthrough_sender and passthrough_recipients chatmail.ini options to eliminate one more source of unencrypted messages
### Features
- Use filtermail for delivery to remote MTAs
- Expose metadata "maxsmtprecipients" value
- Support setup without domain, with only an IPv4 address (#963)
- *(doc/docker)* Introduce docker images in documentation
- DKIM-sign bounce messages (mainly "user does not exist")
- *(config)* Load default values from Config(), not chatmail.ini.f (#853)
- Make turn_socket_path configurable, and cleanup tests and turnserver code.
- Warn about any unused chatmail.ini parameter at the end of "cmdeploy run"
### Bug Fixes
- Make www tests work with editable instead of just plain installs
- Use path with no leading slash for mxdeliv
- Increase filtermail-transport concurrency limit
- Fix #972 by increasing file descriptors for filtermail
- *(mtail)* Correct boot ordering and deploy restart logic
- *(cmdeploy)* Stop and disable unbound-resolvconf
- *(nginx)* Properly redirect www to mail_domain
- *(dns)* Query correct NS if MNAME server is hidden (#954)
- Legacy token metadata storage used list type, but if no new setmetadata happened, the user would not be notified at all.
- *(logging)* Log all http requests to syslog
### Documentation
- Document how to upgrade to new version (#965)
### Other
- *(deps)* Upgrade to filtermail v0.6.4
### Refactor
- Introduce automated change-tracking across deployers
## 1.10.0 2026-04-30
* start mtail after networking is fully up <https://github.com/chatmail/relay/pull/942>
* support specifying custom filtermail binary through environment variable <https://github.com/chatmail/relay/pull/941>
* add automated zizmor scanning of github workflows <https://github.com/chatmail/relay/pull/938>
* added dispatch for *automated builds of chatmail relay docker images* <https://github.com/chatmail/relay/pull/934>
* do not bind SMTP client sockets to public addresses <https://github.com/chatmail/relay/pull/932>
* underline in docs that scripts/initenv.sh should be used for building the docs <https://github.com/chatmail/relay/pull/933>
* automatic oldest-first message removal from mailboxes to always stay under max_mailbox_size <https://github.com/chatmail/relay/pull/929>
* remove --slow from cmdeploy test <https://github.com/chatmail/relay/pull/931>
* handle missing inotify sysctl keys in containers <https://github.com/chatmail/relay/pull/930>
* replace resolvconf with static resolv.conf <https://github.com/chatmail/relay/pull/928>
* disable fsync for LMTP and IMAP services <https://github.com/chatmail/relay/pull/925>
* re-use cmlxc workflow, replacing CI with hetzner staging servers with local lxc containers <https://github.com/chatmail/relay/pull/917>
* explicitly install resolvconf <https://github.com/chatmail/relay/pull/924>
* detect stale dovecot binary and force restart in activate() <https://github.com/chatmail/relay/pull/922>
* Rename filtermail_http_port to filtermail_http_port_incoming <https://github.com/chatmail/relay/pull/921>
* consolidated is_in_container() check https://github.com/chatmail/relay/pull/920>
* restart dovecot after package replacement (rebase, test condense) <https://github.com/chatmail/relay/pull/913>
* Set permissions on dovecot pin prefs <https://github.com/chatmail/relay/pull/915>
* Route `/mxdeliv/` to configurable port <https://github.com/chatmail/relay/pull/901>
* fix VM detection, automated testing fixes, use newer chatmail-turn and move to standard BIND DNS zone format <https://github.com/chatmail/relay/pull/912>
* Upgrade to filtermail 0.6.1 <https://github.com/chatmail/relay/pull/910>
* pin dovecot packages to prevent apt upgrades <https://github.com/chatmail/relay/pull/908>
* add rpc server to cmdeploy along with client <https://github.com/chatmail/relay/pull/906>
* remove unused deps from chatmaild <https://github.com/chatmail/relay/pull/905>
* set default smtp_tls_security_level to "verify" unconditionally <https://github.com/chatmail/relay/pull/902>
* featprefer IPv4 in SMTP client <https://github.com/chatmail/relay/pull/900>
* Install dovecot .deb packages atomically <https://github.com/chatmail/relay/pull/899>
* stop installing cron package <https://github.com/chatmail/relay/pull/898>
* Rewrite dovecot install logic, update <https://github.com/chatmail/relay/pull/862>
* fix a test and some linting fixes <https://github.com/chatmail/relay/pull/897>
* Disable IP verification on domain-literal addresses <https://github.com/chatmail/relay/pull/895>
* disable installing recommended packages globally on the relay <https://github.com/chatmail/relay/pull/887>
* multiple bug fixes across chatmaild and cmdeploy <https://github.com/chatmail/relay/pull/883>
* remove /metrics from the website <https://github.com/chatmail/relay/pull/703>
* add Prometheus textfile output to fsreport <https://github.com/chatmail/relay/pull/881>
* chown opendkim: private key <https://github.com/chatmail/relay/pull/879>
* make sure chatmail-metadata was started <https://github.com/chatmail/relay/pull/882>
* dovecot update url <https://github.com/chatmail/relay/pull/880>
* upgrade to filtermail v0.5.2 <https://github.com/chatmail/relay/pull/876>
* download dovecot packages from github release <https://github.com/chatmail/relay/pull/875>
* replace DKIM verification with filtermail v0.5 <https://github.com/chatmail/relay/pull/831>
* remove CFFI deltachat bindings usage, and consolidate test support with rpc-bindings <https://github.com/chatmail/relay/pull/872>
* prepare chatmaild/cmdeploy changes for Docker support <https://github.com/chatmail/relay/pull/857>
* stabilize online benchmark timing adding rate-limit-aware cooldown between iterations <https://github.com/chatmail/relay/pull/867>
* move rate-limit cooldown to benchmark fixture <https://github.com/chatmail/relay/pull/868>
* reconfigure acmetool from redirector to proxy mode <https://github.com/chatmail/relay/pull/861>
* make tests work with `--ssh-host localhost` <https://github.com/chatmail/relay/pull/856>
* mark f-string with f prefix in test_expunged <https://github.com/chatmail/relay/pull/863>
* install also if dovecot.service=False in SystemdEnabled Fact <https://github.com/chatmail/relay/pull/841>
* Introduce support for self-signed chatmail relays <https://github.com/chatmail/relay/pull/855>
* Strip Received headers before delivery <https://github.com/chatmail/relay/pull/849>
* upgrade to filtermail v0.3 <https://github.com/chatmail/relay/pull/850>
* fix link to Maddy and update madmail URL <https://github.com/chatmail/relay/pull/847>
* accept self-signed certificates for IP-only relays <https://github.com/chatmail/relay/pull/846>
* enforce sending from public IP addresses <https://github.com/chatmail/relay/pull/845>
* port check: check addresses, fix single services <https://github.com/chatmail/relay/pull/844>
* remediates issue with improper concat on resolver injection <https://github.com/chatmail/relay/pull/834>
* ipv6 boolean not being respected during operations <https://github.com/chatmail/relay/pull/832>
* upgrade to filtermail v0.2 by <https://github.com/chatmail/relay/pull/825>
* fix link to filtermail <https://github.com/chatmail/relay/pull/824>
* print timestamps when sending messages <https://github.com/chatmail/relay/pull/823>
* fix flaky test_exceed_rate_limit <https://github.com/chatmail/relay/pull/822>
* Replace filtermail with rust reimplementation <https://github.com/chatmail/relay/pull/808>
* Set default internal SMTP ports in Config <https://github.com/chatmail/relay/pull/819>
* separate metrics for incoming and outgoing messages <https://github.com/chatmail/relay/pull/820>
* disable appending the Received header <https://github.com/chatmail/relay/pull/815>
* fail on errors in postfix/dovecot config <https://github.com/chatmail/relay/pull/813>
* tweak idle/hibernate metrics some more <https://github.com/chatmail/relay/pull/811>
* add config flag to export statistics <https://github.com/chatmail/relay/pull/806>
* add --website-only option to run subcommand <https://github.com/chatmail/relay/pull/768>
* Strip DKIM-Signature header before LMTP <https://github.com/chatmail/relay/pull/803>
* properly make sure that postfix gets restarted on failure <https://github.com/chatmail/relay/pull/802>
* expire.py: use absolute path to maildirsize <https://github.com/chatmail/relay/pull/807>
* pin Dovecot documentation URLs to version 2.3 <https://github.com/chatmail/relay/pull/800>
* try to use "build machine" and "deployment server" consistently <https://github.com/chatmail/relay/pull/797>
* adds instructions for migrating control machines <https://github.com/chatmail/relay/pull/795>
* use consistent naming schema in getting started <https://github.com/chatmail/relay/pull/793>
* remove jsok/serialize-workflow-action dependency <https://github.com/chatmail/relay/pull/790>
* streamline migration guide wording, provide titled steps <https://github.com/chatmail/relay/pull/789>
* increases default max mailbox size <https://github.com/chatmail/relay/pull/792>
* use daemon_name for OpenDKIM sign-verify decision instead of IP <https://github.com/chatmail/relay/pull/784>
## 1.9.0 2025-12-18 ## 1.9.0 2025-12-18
### Documentation ### Documentation
+3
View File
@@ -5,3 +5,6 @@ We use [git-cliff] to generate the changelog from commit messages before the rel
[Conventional Commits]: https://www.conventionalcommits.org/ [Conventional Commits]: https://www.conventionalcommits.org/
[git-cliff]: https://git-cliff.org/ [git-cliff]: https://git-cliff.org/
To update client app version information,
edit [chatmaild/src/chatmaild/defaults/appversions.json](chatmaild/src/chatmaild/defaults/appversions.json).
+6 -1
View File
@@ -8,7 +8,12 @@ Chatmail relay servers are interoperable Mail Transport Agents (MTAs) designed f
- **Instant/Realtime:** sub-second message delivery, realtime P2P - **Instant/Realtime:** sub-second message delivery, realtime P2P
streaming, privacy-preserving Push Notifications for Apple, Google, and Huawei; streaming, privacy-preserving Push Notifications for Apple, Google, and Huawei;
- **Security Enforcement**: only strict TLS, DKIM and OpenPGP with minimized metadata accepted - **Security Enforcement**: Only connections with strict TLS are accepted;
all messages must be correctly signed with DKIM and OpenPGP-encrypted with minimized metadata.
There are experimental exceptions for no-DNS relays,
which are allowed use self-signed TLS certificates
and which do not need to DKIM-sign their messages.
Unencrypted messages are allowed in neither case.
- **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.
+4 -6
View File
@@ -1,15 +1,13 @@
# Releasing a new version of chatmail relay # Releasing a new version of chatmail relay
For example, to release version 1.9.0 of chatmail relay, do the following steps. For example, to release version 1.13.0 of chatmail relay, do the following steps.
1. Update the changelog: `git cliff --unreleased --tag 1.9.0 --prepend CHANGELOG.md` or `git cliff -u -t 1.9.0 -p CHANGELOG.md`. 1. Update the changelog: `git cliff --unreleased --tag 1.13.0 --prepend CHANGELOG.md` or `git cliff -u -t 1.13.0 -p CHANGELOG.md`.
2. Open the changelog in the editor, edit it if required. 2. Open the changelog in the editor, edit it if required.
3. Commit the changes to the changelog with a commit message `chore(release): prepare for 1.9.0`. 3. Commit the changes to the changelog with a commit message `chore(release): prepare for 1.9.0`.
3. Tag the release: `git tag --annotate 1.9.0`. 4. Open a PR with the new commit, merge it to main after review.
4. Push the release tag: `git push origin 1.9.0`. 5. In the web interface, create a GitHub release, tell it to create a new tag.
5. Create a GitHub release: `gh release create 1.9.0`.
+1
View File
@@ -1,3 +1,4 @@
include src/chatmaild/defaults/*.json
include src/chatmaild/ini/*.ini.f include src/chatmaild/ini/*.ini.f
include src/chatmaild/ini/*.ini include src/chatmaild/ini/*.ini
include src/chatmaild/tests/mail-data/* include src/chatmaild/tests/mail-data/*
+2 -5
View File
@@ -8,8 +8,9 @@ version = "0.3"
dependencies = [ dependencies = [
"iniconfig", "iniconfig",
"filelock", "filelock",
"psutil",
"requests", "requests",
"crypt-r >= 3.13.1 ; python_version >= '3.11'", "crypt-r >= 3.13.1 ; python_version >= '3.13'",
] ]
[tool.setuptools] [tool.setuptools]
@@ -25,10 +26,6 @@ chatmail-expire = "chatmaild.expire:daily_expire_main"
chatmail-quota-expire = "chatmaild.expire:quota_expire_main" chatmail-quota-expire = "chatmaild.expire:quota_expire_main"
chatmail-fsreport = "chatmaild.fsreport:main" chatmail-fsreport = "chatmaild.fsreport:main"
lastlogin = "chatmaild.lastlogin:main" lastlogin = "chatmaild.lastlogin:main"
turnserver = "chatmaild.turnserver:main"
[project.entry-points.pytest11]
"chatmaild.testplugin" = "chatmaild.tests.plugin"
[tool.pytest.ini_options] [tool.pytest.ini_options]
addopts = "-v -ra --strict-markers" addopts = "-v -ra --strict-markers"
+78 -66
View File
@@ -1,3 +1,4 @@
import ipaddress
from pathlib import Path from pathlib import Path
import iniconfig import iniconfig
@@ -8,64 +9,89 @@ 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"] params = dict(params)
self.max_user_send_per_minute = int(params.get("max_user_send_per_minute", 60)) raw_domain = params.pop("mail_domain")
self.max_user_send_burst_size = int(params.get("max_user_send_burst_size", 10)) self.mail_domain_bare = raw_domain
self.max_mailbox_size = params["max_mailbox_size"]
self.max_message_size = int(params.get("max_message_size", "31457280")) if is_valid_ipv4(raw_domain):
self.delete_mails_after = params["delete_mails_after"] self.ipv4_relay = raw_domain
self.delete_large_after = params["delete_large_after"] self.mail_domain = f"[{raw_domain}]"
self.delete_inactive_users_after = int(params["delete_inactive_users_after"]) self.postfix_myhostname = ipaddress.IPv4Address(raw_domain).reverse_pointer
self.username_min_length = int(params["username_min_length"]) else:
self.username_max_length = int(params["username_max_length"]) self.ipv4_relay = None
self.password_min_length = int(params["password_min_length"]) self.mail_domain = raw_domain
self.passthrough_senders = params["passthrough_senders"].split() self.postfix_myhostname = raw_domain
self.passthrough_recipients = params["passthrough_recipients"].split()
self.www_folder = params.get("www_folder", "") self.max_user_send_per_minute = int(params.pop("max_user_send_per_minute", 60))
self.filtermail_smtp_port = int(params.get("filtermail_smtp_port", "10080")) self.max_user_send_burst_size = int(params.pop("max_user_send_burst_size", 10))
self.max_mailbox_size = params.pop("max_mailbox_size", "500M")
self.max_message_size = int(params.pop("max_message_size", 31457280))
self.delete_mails_after = params.pop("delete_mails_after", "20")
self.delete_large_after = params.pop("delete_large_after", "7")
self.delete_inactive_users_after = int(
params.pop("delete_inactive_users_after", 90)
)
self.username_min_length = int(params.pop("username_min_length", 9))
self.username_max_length = int(params.pop("username_max_length", 9))
self.password_min_length = int(params.pop("password_min_length", 9))
self.www_folder = params.pop("www_folder", "")
self.filtermail_smtp_port = int(params.pop("filtermail_smtp_port", "10080"))
self.filtermail_smtp_port_incoming = int( self.filtermail_smtp_port_incoming = int(
params.get("filtermail_smtp_port_incoming", "10081") params.pop("filtermail_smtp_port_incoming", "10081")
) )
self.filtermail_http_port_incoming = int( self.filtermail_http_port_incoming = int(
params.get("filtermail_http_port_incoming", "10082") params.pop("filtermail_http_port_incoming", "10082")
) )
self.postfix_reinject_port = int(params.get("postfix_reinject_port", "10025")) self.filtermail_lmtp_port_transport = int(
params.pop("filtermail_lmtp_port_transport", "10083")
)
self.postfix_reinject_port = int(params.pop("postfix_reinject_port", "10025"))
self.postfix_reinject_port_incoming = int( self.postfix_reinject_port_incoming = int(
params.get("postfix_reinject_port_incoming", "10026") params.pop("postfix_reinject_port_incoming", "10026")
) )
self.mtail_address = params.get("mtail_address") self.doveauth_http_port = int(params.pop("doveauth_http_port", "10084"))
self.disable_ipv6 = params.get("disable_ipv6", "false").lower() == "true" self.mtail_address = params.pop("mtail_address", None)
self.acme_email = params.get("acme_email", "") self.disable_ipv6 = params.pop("disable_ipv6", "false").lower() == "true"
self.imap_rawlog = params.get("imap_rawlog", "false").lower() == "true" self.acme_email = params.pop("acme_email", "")
self.imap_compress = params.get("imap_compress", "false").lower() == "true" self.imap_rawlog = params.pop("imap_rawlog", "false").lower() == "true"
if "iroh_relay" not in params: self.imap_compress = params.pop("imap_compress", "false").lower() == "true"
self.iroh_relay = "https://" + params["mail_domain"] self.turn_socket_path = params.pop(
"turn_socket_path", "/run/chatmail-turn/turn.socket"
)
iroh_relay = params.pop("iroh_relay", None)
if iroh_relay is None:
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 = iroh_relay.strip()
self.enable_iroh_relay = False self.enable_iroh_relay = False
self.privacy_postal = params.get("privacy_postal") self.privacy_postal = params.pop("privacy_postal", None)
self.privacy_mail = params.get("privacy_mail") self.privacy_mail = params.pop("privacy_mail", None)
self.privacy_pdo = params.get("privacy_pdo") self.privacy_pdo = params.pop("privacy_pdo", None)
self.privacy_supervisor = params.get("privacy_supervisor") self.privacy_supervisor = params.pop("privacy_supervisor", None)
self.max_load_1m = float(params.pop("max_load_1m", 5))
self.min_available_memory_mb = parse_size_mb(
params.pop("min_available_memory", "200M")
)
self.min_free_disk_space_mb = parse_size_mb(
params.pop("min_free_disk_space", "1G")
)
self.max_imap_connections = int(params.pop("max_imap_connections", 10000))
self.max_smtp_connections = int(params.pop("max_smtp_connections", 1000))
# TLS certificate management. # TLS certificate management.
# If tls_external_cert_and_key is set, use externally managed certs. # If tls_external_cert_and_key is set, use externally managed certs.
# Otherwise derived from the domain name: # Otherwise derived from the domain name:
# - Domains starting with "_" use self-signed certificates # - Domains starting with "_" use self-signed certificates
# - All other domains use ACME. # - All other domains use ACME.
external = params.get("tls_external_cert_and_key", "").strip() external = params.pop("tls_external_cert_and_key", "").strip()
if external: if external:
parts = external.split() parts = external.split()
@@ -76,21 +102,22 @@ 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.pop("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)
self.passdb_path = Path(params.get("passdb_path", "/home/vmail/passdb.sqlite")) self.passdb_path = Path(params.pop("passdb_path", "/home/vmail/passdb.sqlite"))
self._unused_keys = list(params)
@property @property
def max_mailbox_size_mb(self): def max_mailbox_size_mb(self):
@@ -147,28 +174,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
@@ -0,0 +1,15 @@
{
"clients": [
{
"clientId": "deltachat",
"sources": [
{
"sourceId": "gplay",
"versionInteger": 757,
"versionString": "2.59.1",
"downloadUrl": "https://github.com/deltachat/deltachat-android/releases/download/v2.59.1/deltachat-gplay-release-2.59.1.apk"
}
]
}
]
}
+111 -94
View File
@@ -1,10 +1,17 @@
import json """Create chatmail addresses on first login.
Dovecot only asks us about addresses it does not already find in the mailbox:
the auth.lua we deploy with dovecot (cmdeploy/src/cmdeploy/dovecot/auth.lua.j2)
verifies existing users itself against a mailbox password file,
and HTTP-POSTs everything else to the /create endpoint implemented in this module.
"""
import logging import logging
import os import os
import re import re
import sys import sys
import threading
import filelock from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
try: try:
import crypt_r import crypt_r
@@ -12,8 +19,8 @@ except ImportError:
import crypt as crypt_r import crypt as crypt_r
from .config import Config, read_config from .config import Config, read_config
from .dictproxy import DictProxy
from .migrate_db import migrate_from_db_to_maildir from .migrate_db import migrate_from_db_to_maildir
from .syslimits import has_sufficient_resources
NOCREATE_FILE = "/etc/chatmail-nocreate" NOCREATE_FILE = "/etc/chatmail-nocreate"
VALID_LOCALPART_RE = re.compile(r"^[a-z0-9._-]+$") VALID_LOCALPART_RE = re.compile(r"^[a-z0-9._-]+$")
@@ -63,107 +70,117 @@ def is_allowed_to_create(config: Config, user, cleartext_password) -> bool:
return True return True
def split_and_unescape(s): def verify_password(stored, cleartext_password) -> bool:
"""Split strings using double quote as a separator and backslash as escape character if stored.startswith("{"):
into parts.""" stored = stored.split("}", 1)[1]
return crypt_r.crypt(cleartext_password, stored) == stored
out = ""
i = 0
while i < len(s):
c = s[i]
if c == "\\":
# Skip escape character.
i += 1
# This will raise IndexError if there is no character
# after escape character. This is expected
# as this is an invalid input.
out += s[i]
elif c == '"':
# Separator
yield out
out = ""
else:
out += c
i += 1
yield out
class AuthDictProxy(DictProxy): class DoveAuth:
def __init__(self, config): def __init__(self, config):
super().__init__()
self.config = config self.config = config
self.creation_lock = threading.Lock()
def handle_lookup(self, parts): def create_user(self, addr, cleartext_password) -> bool:
# Dovecot <2.3.17 has only one part, """Create the address, or verify the password if it exists already."""
# do not attempt to read any other parts for compatibility.
keyname = parts[0]
namespace, type, args = keyname.split("/", 2)
args = list(split_and_unescape(args))
config = self.config config = self.config
reply_command = "F" if not addr.endswith(f"@{config.mail_domain}"):
res = "" logging.warning("address not in mail domain: %r", addr)
if namespace == "shared": return False
if type == "userdb": try:
user = args[0] user = config.get_user(addr)
if user.endswith(f"@{config.mail_domain}"): except ValueError:
res = self.lookup_userdb(user) logging.warning("invalid address: %r", addr)
if res: return False
reply_command = "O" with self.creation_lock:
else: passhash = user.get_password_hash()
reply_command = "N" if passhash is not None:
elif type == "passdb": # a concurrent first login may have just created the address
user = args[1] return verify_password(passhash, cleartext_password)
if user.endswith(f"@{config.mail_domain}"): if not is_allowed_to_create(config, addr, cleartext_password):
res = self.lookup_passdb(user, cleartext_password=args[0]) return False
if res: if not has_sufficient_resources(config):
reply_command = "O" return False
else:
reply_command = "N"
json_res = json.dumps(res) if res else ""
return f"{reply_command}{json_res}\n"
def handle_iterate(self, parts):
# example: I0\t0\tshared/userdb/
if parts[2] == "shared/userdb/":
result = "".join(
f"Oshared/userdb/{user}\t\n" for user in self.iter_userdb()
)
return f"{result}\n"
def iter_userdb(self) -> list:
"""Get a list of all user addresses."""
return [x for x in os.listdir(self.config.mailboxes_dir) if "@" in x]
def lookup_userdb(self, addr):
return self.config.get_user(addr).get_userdb_dict()
def lookup_passdb(self, addr, cleartext_password):
user = self.config.get_user(addr)
userdata = user.get_userdb_dict()
if userdata:
return userdata
if not is_allowed_to_create(self.config, addr, cleartext_password):
return
lock = filelock.FileLock(str(user.password_path) + ".lock", timeout=5)
with lock:
userdata = user.get_userdb_dict()
if userdata:
return userdata
user.set_password(encrypt_password(cleartext_password)) user.set_password(encrypt_password(cleartext_password))
print(f"Created address: {addr}", file=sys.stderr) # mtail counts created_accounts off this exact line
return user.get_userdb_dict() print(f"Created address: {addr}", file=sys.stderr)
return True
class CreateHandler(BaseHTTPRequestHandler):
"""Answer POST /create requests from dovecot's auth.lua, body `addr\\tpassword`.
The body must be UTF-8 and only the first tab separates the fields,
so a password may itself contain tabs.
Any non-UTF8 or \\0 bytes in the body fail the request.
Addresses are ASCII: dovecot refuses any login name outside its
auth_username_chars before auth.lua ever sees it.
Dovecot hands auth.lua the exact password bytes the client sent;
decoding and re-encoding UTF-8 is byte-identical,
so dovecot's password_verify later recomputes the same hash crypt() stores here.
"""
protocol_version = "HTTP/1.1" # dovecot's HTTP client reuses connections
max_body_len = 512 # an address and a password
def do_POST(self):
if self.path != "/create":
self.reply(404)
return
length = self.body_length()
if length is None:
self.reply(400)
return
body = self.rfile.read(length)
try:
addr, _, password = body.decode("utf-8").partition("\t")
except UnicodeDecodeError:
self.reply(400)
return
if "\0" in addr or "\0" in password:
self.reply(400)
return
self.reply(200 if self.server.doveauth.create_user(addr, password) else 403)
def body_length(self):
try:
length = int(self.headers["Content-Length"])
except (TypeError, ValueError):
return None
return length if 0 <= length <= self.max_body_len else None
def reply(self, status):
self.send_response(status)
self.send_header("Content-Length", "0")
if status != 200:
# Just close on any failure, as body might not be fully read.
# It's anyway cheap to re-establish http localhost without TLS.
self.send_header("Connection", "close")
self.end_headers()
def log_message(self, format, *args):
# the per-request access log would only duplicate our own stderr lines
pass
class DoveAuthServer(ThreadingHTTPServer):
# a burst of first-time logins (e.g. from CI) must not overflow
# the accept queue, see https://github.com/chatmail/relay/issues/436
request_queue_size = 1000
def __init__(self, config, port):
super().__init__(("127.0.0.1", port), CreateHandler)
self.doveauth = DoveAuth(config)
def main(): def main():
socket, cfgpath = sys.argv[1:] (cfgpath,) = sys.argv[1:]
config = read_config(cfgpath) config = read_config(cfgpath)
migrate_from_db_to_maildir(config) migrate_from_db_to_maildir(config)
dictproxy = AuthDictProxy(config=config) server = DoveAuthServer(config, config.doveauth_http_port)
server.serve_forever()
dictproxy.serve_forever_from_socket(socket)
+11 -1
View File
@@ -168,6 +168,16 @@ class Expiry:
if mbox.last_login and mbox.last_login < cutoff_without_login: if mbox.last_login and mbox.last_login < cutoff_without_login:
self.remove_mailbox(mbox.basedir) self.remove_mailbox(mbox.basedir)
return return
elif mbox.last_login is None:
try:
if not self.dry:
os.rmdir(mbox.basedir)
self.del_mboxes += 1
except OSError:
print_info(
f"Skipped deleting {mbox.basedir}, doesn't have last_login but isn't empty"
)
return
mboxname = os.path.basename(mbox.basedir) mboxname = os.path.basename(mbox.basedir)
if self.verbose: if self.verbose:
@@ -249,7 +259,7 @@ def daily_expire_main(args=None):
args = parser.parse_args(args) args = parser.parse_args(args)
config = read_config(args.chatmail_ini) config = read_config(args.chatmail_ini)
now = datetime.utcnow().timestamp() now = time.time()
if args.days: if args.days:
now = now - 86400 * int(args.days) now = now - 86400 * int(args.days)
+2 -1
View File
@@ -27,6 +27,7 @@ to also write legacy metrics.py style output (default: /var/www/html/metrics):
import os import os
import tempfile import tempfile
import time
from argparse import ArgumentParser from argparse import ArgumentParser
from datetime import datetime from datetime import datetime
@@ -264,7 +265,7 @@ def main(args=None):
config = read_config(args.chatmail_ini) config = read_config(args.chatmail_ini)
now = datetime.utcnow().timestamp() now = time.time()
if args.days: if args.days:
now = now - 86400 * int(args.days) now = now - 86400 * int(args.days)
+40 -28
View File
@@ -12,42 +12,62 @@ 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 #
passthrough_senders = # System resource limits
#
# list of e-mail recipients for which to accept outbound un-encrypted mails # The following three limits refuse creation of new addresses
# (space-separated, item may start with "@" to whitelist whole recipient domains) # while existing addresses keep working.
passthrough_recipients = # Rejections are logged by the doveauth service.
# Maximum 1-minute load average, as reported by "uptime";
# it counts processes waiting for disk I/O as well as for CPU.
#max_load_1m = 5
# Minimum memory available without swapping.
#min_available_memory = 200M
# Minimum free disk space on the file system holding the mailboxes.
#min_free_disk_space = 1G
# Maximum number of concurrent IMAP connections
# (the Dovecot imap process limit).
#max_imap_connections = 10000
# Maximum number of concurrent SMTP connections
# on each of the submission and smtps ports (the Postfix process limit).
# A single client IP may use up to a fifth of this.
#max_smtp_connections = 1000
# 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 +83,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 +120,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
# #
@@ -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.
+67 -29
View File
@@ -1,13 +1,35 @@
import json
import logging import logging
import socket
import sys import sys
import time import time
from contextlib import contextmanager from contextlib import contextmanager
from importlib.resources import files
from .config import read_config from .config import read_config
from .dictproxy import DictProxy from .dictproxy import DictProxy
from .filedict import FileDict from .filedict import FileDict
from .notifier import Notifier from .notifier import Notifier
from .turnserver import turn_credentials
def turn_credentials(turn_socket_path):
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client_socket:
client_socket.settimeout(5)
client_socket.connect(turn_socket_path)
with client_socket.makefile("rb") as file:
return file.readline().decode("utf-8").strip()
def read_appversions(path):
try:
data = json.loads(path.read_bytes())
except FileNotFoundError:
return None
except (OSError, ValueError):
logging.exception(f"failed to read {path}")
return None
# the dict protocol is line-based, keep the value single-line
return json.dumps(data, separators=(",", ":"))
def _is_valid_token_timestamp(timestamp, now): def _is_valid_token_timestamp(timestamp, now):
@@ -70,44 +92,57 @@ 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
class MetadataDictProxy(DictProxy): class MetadataDictProxy(DictProxy):
def __init__(self, notifier, metadata, iroh_relay=None, turn_hostname=None): def __init__(
self,
notifier,
metadata,
iroh_relay=None,
turn_hostname=None,
turn_socket_path=None,
):
super().__init__() super().__init__()
self.notifier = notifier self.notifier = notifier
self.metadata = metadata self.metadata = metadata
self.iroh_relay = iroh_relay self.iroh_relay = iroh_relay
self.turn_hostname = turn_hostname self.turn_hostname = turn_hostname
self.turn_socket_path = turn_socket_path
self.appversions_path = files(__package__).joinpath("defaults/appversions.json")
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(self.turn_socket_path)
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"
case "appversions":
value = read_appversions(self.appversions_path)
return f"O{value}\n" if value else "N\n"
logging.warning(f"lookup ignored: {parts!r}") logging.warning(f"lookup ignored: {parts!r}")
return "N\n" return "N\n"
@@ -117,12 +152,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
@@ -133,6 +169,7 @@ def main():
config = read_config(config_path) config = read_config(config_path)
iroh_relay = config.iroh_relay iroh_relay = config.iroh_relay
mail_domain = config.mail_domain mail_domain = config.mail_domain
socket_path = config.turn_socket_path
vmail_dir = config.mailboxes_dir vmail_dir = config.mailboxes_dir
if not vmail_dir.exists(): if not vmail_dir.exists():
@@ -150,6 +187,7 @@ def main():
metadata=metadata, metadata=metadata,
iroh_relay=iroh_relay, iroh_relay=iroh_relay,
turn_hostname=mail_domain, turn_hostname=mail_domain,
turn_socket_path=socket_path,
) )
dictproxy.serve_forever_from_socket(socket) dictproxy.serve_forever_from_socket(socket)
+12 -15
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("")
+32
View File
@@ -0,0 +1,32 @@
"""Detect whether the system is at its limits."""
import logging
import psutil
MB = 1024 * 1024
def read_value(getter):
try:
return getter()
except Exception as e:
logging.warning("ignoring unreadable system limit: %s", e)
return None
def has_sufficient_resources(config):
"""Return False if load, memory or disk exceeds a configured limit."""
load = read_value(lambda: psutil.getloadavg()[0])
mem = read_value(lambda: psutil.virtual_memory().available // MB)
disk = read_value(lambda: psutil.disk_usage(str(config.mailboxes_dir)).free // MB)
if load is not None and load > config.max_load_1m:
msg = f"load avg {load:.2f} > {config.max_load_1m:.2f}"
elif mem is not None and mem < config.min_available_memory_mb:
msg = f"available memory {mem}MB < {config.min_available_memory_mb}MB"
elif disk is not None and disk < config.min_free_disk_space_mb:
msg = f"free disk {disk}MB < {config.min_free_disk_space_mb}MB"
else:
return True
logging.warning("registration rejected: %s", msg)
return False
@@ -0,0 +1,3 @@
"""Opt in to the chatmaild fixtures, which are not registered globally."""
from chatmaild.tests.plugin import * # noqa: F403
+14 -5
View File
@@ -20,6 +20,10 @@ def make_config(tmp_path):
basedir.mkdir(parents=True, exist_ok=True) basedir.mkdir(parents=True, exist_ok=True)
overrides = settings.copy() if settings else {} overrides = settings.copy() if settings else {}
overrides["mailboxes_dir"] = str(basedir) overrides["mailboxes_dir"] = str(basedir)
# permissive resource limits so tests never depend on host load/memory/disk
overrides.setdefault("max_load_1m", "99999")
overrides.setdefault("min_available_memory", "0")
overrides.setdefault("min_free_disk_space", "0")
write_initial_config(inipath, mail_domain, overrides=overrides) write_initial_config(inipath, mail_domain, overrides=overrides)
return read_config(inipath) return read_config(inipath)
@@ -32,22 +36,27 @@ def example_config(make_config):
@pytest.fixture @pytest.fixture
def maildomain(example_config): def ipv4_config(make_config):
return make_config("1.3.3.7")
@pytest.fixture
def example_maildomain(example_config):
return example_config.mail_domain return example_config.mail_domain
@pytest.fixture @pytest.fixture
def testaddr(maildomain): def testaddr(example_maildomain):
return f"user.name@{maildomain}" return f"user.name@{example_maildomain}"
@pytest.fixture @pytest.fixture
def gencreds(maildomain): def example_gencreds(example_maildomain):
count = itertools.count() count = itertools.count()
next(count) next(count)
def gen(domain=None): def gen(domain=None):
domain = domain if domain else maildomain domain = domain if domain else example_maildomain
while 1: while 1:
num = next(count) num = next(count)
alphanumeric = "abcdefghijklmnopqrstuvwxyz1234567890" alphanumeric = "abcdefghijklmnopqrstuvwxyz1234567890"
@@ -0,0 +1,96 @@
import json
import pytest
from chatmaild.metadata import MetadataDictProxy
ALLOWED_URL_PREFIXES = (
"https://github.com/deltachat/",
"https://download.delta.chat/",
)
def check_string(value):
assert isinstance(value, str), value
assert value
def check_version_integer(value):
# core parses this as u32, see https://github.com/chatmail/core/pull/8557
assert isinstance(value, int) and not isinstance(value, bool), value
assert 0 <= value < 2**32, value
def check_appversions(data):
"""Verifies the file the way core parses it.
core deserializes into typed structs and drops the whole payload
of a relay if a single value has an unexpected type,
while missing or misspelled keys silently turn into defaults.
"""
assert set(data) == {"clients"}, data
assert isinstance(data["clients"], list)
assert data["clients"]
client_ids = []
for client in data["clients"]:
assert set(client) == {"clientId", "sources"}, client
check_string(client["clientId"])
client_ids.append(client["clientId"])
assert isinstance(client["sources"], list)
assert client["sources"]
source_ids = []
for source in client["sources"]:
assert set(source) == {
"sourceId",
"versionInteger",
"versionString",
"downloadUrl",
}, source
check_string(source["sourceId"])
source_ids.append(source["sourceId"])
check_version_integer(source["versionInteger"])
check_string(source["versionString"])
check_string(source["downloadUrl"])
assert source["downloadUrl"].startswith(ALLOWED_URL_PREFIXES)
# core takes the first matching source, later duplicates never surface
assert len(set(source_ids)) == len(source_ids), source_ids
assert len(set(client_ids)) == len(client_ids), client_ids
@pytest.fixture
def appversions():
# check the file which chatmail-metadata actually serves
path = MetadataDictProxy(notifier=None, metadata=None).appversions_path
return json.loads(path.read_text())
def test_appversions_schema(appversions):
check_appversions(appversions)
@pytest.mark.parametrize("value", [True, -1, 2**32, "754", 754.0, None])
def test_version_integer_rejected(appversions, value):
appversions["clients"][0]["sources"][0]["versionInteger"] = value
with pytest.raises(AssertionError):
check_appversions(appversions)
@pytest.mark.parametrize("key", ["clientId", "sources"])
def test_misspelled_client_key_rejected(appversions, key):
client = appversions["clients"][0]
client[key + "s"] = client.pop(key)
with pytest.raises(AssertionError):
check_appversions(appversions)
def test_duplicate_source_id_rejected(appversions):
sources = appversions["clients"][0]["sources"]
sources.append(dict(sources[0]))
with pytest.raises(AssertionError):
check_appversions(appversions)
def test_foreign_download_url_rejected(appversions):
appversions["clients"][0]["sources"][0]["downloadUrl"] = "https://example.org/x.apk"
with pytest.raises(AssertionError):
check_appversions(appversions)
+48 -22
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,38 +13,46 @@ 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_basic_using_defaults(tmp_path, maildomain): 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, example_maildomain):
inipath = tmp_path.joinpath("chatmail.ini") inipath = tmp_path.joinpath("chatmail.ini")
inipath.write_text(f"[params]\nmail_domain = {maildomain}") inipath.write_text(f"[params]\nmail_domain = {example_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
assert example_config.max_user_send_per_minute == 60
assert example_config.max_mailbox_size == "500M"
assert example_config.delete_mails_after == "20"
assert example_config.delete_large_after == "7"
assert example_config.username_min_length == 9
assert example_config.username_max_length == 9
assert example_config.password_min_length == 9
assert example_config.max_imap_connections == 10000
assert example_config.max_smtp_connections == 1000
assert example_config._unused_keys == []
def test_read_config_testrun(make_config): def test_config_unused_keys(make_config):
config = make_config("something.testrun.org") config = make_config("chat.example.org", {"passthrough_senders": "x@y.org"})
assert config.mail_domain == "something.testrun.org" assert config._unused_keys == ["passthrough_senders"]
assert len(config.privacy_postal.split("\n")) > 1
assert len(config.privacy_supervisor.split("\n")) > 1
assert len(config.privacy_pdo.split("\n")) > 1
assert config.privacy_mail == "privacy@testrun.org"
assert config.filtermail_smtp_port == 10080
assert config.postfix_reinject_port == 10025
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 +147,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)
@@ -1,6 +1,6 @@
import time import time
from chatmaild.doveauth import AuthDictProxy from chatmaild.doveauth import DoveAuth
from chatmaild.expire import daily_expire_main as main_expire from chatmaild.expire import daily_expire_main as main_expire
@@ -18,10 +18,10 @@ def test_login_timestamps(example_config):
def test_delete_inactive_users(example_config): def test_delete_inactive_users(example_config):
new = time.time() new = time.time()
old = new - (example_config.delete_inactive_users_after * 86400) - 1 old = new - (example_config.delete_inactive_users_after * 86400) - 1
dictproxy = AuthDictProxy(example_config) doveauth = DoveAuth(example_config)
def create_user(addr, last_login): def create_user(addr, last_login):
dictproxy.lookup_passdb(addr, "q9mr3faue") doveauth.create_user(addr, "q9mr3faue")
user = example_config.get_user(addr) user = example_config.get_user(addr)
user.maildir.joinpath("cur").mkdir() user.maildir.joinpath("cur").mkdir()
user.maildir.joinpath("cur", "something").mkdir() user.maildir.joinpath("cur", "something").mkdir()
+168 -130
View File
@@ -1,42 +1,37 @@
import io import http.client
import json
import queue
import threading import threading
import traceback from concurrent.futures import ThreadPoolExecutor
import pytest import pytest
import chatmaild.doveauth import chatmaild.doveauth
from chatmaild.doveauth import ( from chatmaild.doveauth import (
AuthDictProxy, CreateHandler,
DoveAuth,
DoveAuthServer,
is_allowed_to_create, is_allowed_to_create,
) )
from chatmaild.newemail import create_newemail_dict from chatmaild.newemail import create_newemail_dict
@pytest.fixture @pytest.fixture
def dictproxy(example_config): def doveauth(example_config):
return AuthDictProxy(config=example_config) return DoveAuth(example_config)
def test_basic(dictproxy, gencreds): def stored_hash(config, addr):
addr, password = gencreds() return config.get_user(addr).get_password_hash()
dictproxy.lookup_passdb(addr, password)
data = dictproxy.lookup_userdb(addr)
assert data
data2 = dictproxy.lookup_passdb(addr, password)
assert data == data2
def test_iterate_addresses(dictproxy): def test_basic(doveauth, example_config, example_gencreds):
addresses = [] addr, password = example_gencreds()
assert doveauth.create_user(addr, password)
passhash = stored_hash(example_config, addr)
assert passhash.startswith("{SHA512-CRYPT}")
for i in range(10): # a second login verifies against the stored hash and rewrites nothing
addresses.append(f"asdf1234{i}@chat.example.org") assert doveauth.create_user(addr, password)
dictproxy.lookup_passdb(addresses[-1], "q9mr3faue") assert stored_hash(example_config, addr) == passhash
res = dictproxy.iter_userdb()
assert set(res) == set(addresses)
def test_invalid_username_length(example_config): def test_invalid_username_length(example_config):
@@ -53,75 +48,32 @@ def test_invalid_username_length(example_config):
) )
def test_dont_overwrite_password_on_wrong_login(dictproxy): def test_dont_overwrite_password_on_wrong_login(doveauth, example_config):
"""Test that logging in with a different password doesn't create a new user""" addr = "newuser12@chat.example.org"
res = dictproxy.lookup_passdb( assert doveauth.create_user(addr, "kajdlkajsldk12l3kj1983")
"newuser12@chat.example.org", "kajdlkajsldk12l3kj1983" passhash = stored_hash(example_config, addr)
)
assert res["password"] assert not doveauth.create_user(addr, "kajdslqwe")
res2 = dictproxy.lookup_passdb("newuser12@chat.example.org", "kajdslqwe") assert stored_hash(example_config, addr) == passhash
# this function always returns a password hash, which is actually compared by dovecot.
assert res["password"] == res2["password"] assert doveauth.create_user(addr, "kajdlkajsldk12l3kj1983")
assert stored_hash(example_config, addr) == passhash
def test_nocreate_file(monkeypatch, tmpdir, dictproxy): def test_foreign_domain_is_refused(doveauth):
assert not doveauth.create_user("newuser12@evil.example.org", "qlwkejqlwe12")
def test_nocreate_file(monkeypatch, tmpdir, doveauth, example_config):
p = tmpdir.join("nocreate") p = tmpdir.join("nocreate")
p.write("") p.write("")
monkeypatch.setattr(chatmaild.doveauth, "NOCREATE_FILE", str(p)) monkeypatch.setattr(chatmaild.doveauth, "NOCREATE_FILE", str(p))
dictproxy.lookup_passdb("newuser12@chat.example.org", "zequ0Aimuchoodaechik") addr = "newuser12@chat.example.org"
assert not dictproxy.lookup_userdb("newuser12@chat.example.org") assert not doveauth.create_user(addr, "zequ0Aimuchoodaechik")
assert stored_hash(example_config, addr) is None
def test_handle_dovecot_request(dictproxy):
transactions = {}
# Test that password can contain ", ', \ and /
msg = (
'Lshared/passdb/laksjdlaksjdlak\\\\sjdlk\\"12j\\\'3l1/k2j3123"'
"some42123@chat.example.org\tsome42123@chat.example.org"
)
res = dictproxy.handle_dovecot_request(msg, transactions)
assert res
assert res[0] == "O" and res.endswith("\n")
userdata = json.loads(res[1:].strip())
assert userdata["home"].endswith("chat.example.org/some42123@chat.example.org")
assert userdata["uid"] == userdata["gid"] == "vmail"
assert userdata["password"].startswith("{SHA512-CRYPT}")
def test_handle_dovecot_protocol_hello_is_skipped(example_config, caplog):
dictproxy = AuthDictProxy(config=example_config)
rfile = io.BytesIO(b"H3\t2\t0\t\tauth\n")
wfile = io.BytesIO()
dictproxy.loop_forever(rfile, wfile)
assert wfile.getvalue() == b""
assert not caplog.messages
def test_handle_dovecot_protocol_user_not_exists(example_config):
dictproxy = AuthDictProxy(config=example_config)
rfile = io.BytesIO(
b"H3\t2\t0\t\tauth\nLshared/userdb/foobar@chat.example.org\tfoobar@chat.example.org\n"
)
wfile = io.BytesIO()
dictproxy.loop_forever(rfile, wfile)
assert wfile.getvalue() == b"N\n"
def test_handle_dovecot_protocol_iterate(gencreds, example_config):
dictproxy = AuthDictProxy(config=example_config)
dictproxy.lookup_passdb("asdf00000@chat.example.org", "q9mr3faue")
dictproxy.lookup_passdb("asdf11111@chat.example.org", "q9mr3faue")
rfile = io.BytesIO(b"H3\t2\t0\t\tauth\nI0\t0\tshared/userdb/")
wfile = io.BytesIO()
dictproxy.loop_forever(rfile, wfile)
lines = wfile.getvalue().decode("ascii").split("\n")
assert "Oshared/userdb/asdf00000@chat.example.org\t" in lines
assert "Oshared/userdb/asdf11111@chat.example.org\t" in lines
assert not lines[2]
def test_invalid_localpart_characters(make_config): def test_invalid_localpart_characters(make_config):
"""Test that is_allowed_to_create rejects localparts with invalid characters."""
config = make_config("chat.example.org", {"username_min_length": "3"}) config = make_config("chat.example.org", {"username_min_length": "3"})
password = "zequ0Aimuchoodaechik" password = "zequ0Aimuchoodaechik"
domain = config.mail_domain domain = config.mail_domain
@@ -141,64 +93,150 @@ def test_invalid_localpart_characters(make_config):
assert not is_allowed_to_create(config, f"ab@cdef@{domain}", password) assert not is_allowed_to_create(config, f"ab@cdef@{domain}", password)
assert not is_allowed_to_create(config, f"abc/def@{domain}", password) assert not is_allowed_to_create(config, f"abc/def@{domain}", password)
assert not is_allowed_to_create(config, f"abc\\def@{domain}", password) assert not is_allowed_to_create(config, f"abc\\def@{domain}", password)
assert not is_allowed_to_create(config, f"üser123@{domain}", password)
def test_concurrent_creation_same_account(dictproxy): def test_concurrent_creation_same_account(doveauth, example_config, capsys):
"""Test that concurrent creation of the same account doesn't corrupt password."""
addr = "racetest1@chat.example.org" addr = "racetest1@chat.example.org"
password = "zequ0Aimuchoodaechik" password = "zequ0Aimuchoodaechik"
num_threads = 10
results = queue.Queue()
def create(): def create(_):
try: ok = doveauth.create_user(addr, password)
res = dictproxy.lookup_passdb(addr, password) return ok, stored_hash(example_config, addr)
results.put(("ok", res))
except Exception:
results.put(("err", traceback.format_exc()))
threads = [threading.Thread(target=create, daemon=True) for _ in range(num_threads)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=10)
passwords_seen = set()
for _ in range(num_threads):
status, res = results.get()
if status == "err":
pytest.fail(f"concurrent creation failed\n{res}")
passwords_seen.add(res["password"])
with ThreadPoolExecutor(10) as pool:
results = list(pool.map(create, range(10)))
assert all(ok for ok, _ in results)
# all threads must see the same password hash # all threads must see the same password hash
assert len(passwords_seen) == 1 assert len({passhash for _, passhash in results}) == 1
assert capsys.readouterr().err.count("Created address:") == 1
def test_50_concurrent_lookups_different_accounts(gencreds, dictproxy): def test_insufficient_resources_block_creation_not_existing_logins(
num_threads = 50 doveauth, example_gencreds, monkeypatch
req_per_thread = 5 ):
results = queue.Queue() addr, password = example_gencreds()
assert doveauth.create_user(addr, password)
def lookup(): monkeypatch.setattr(
for i in range(req_per_thread): chatmaild.doveauth, "has_sufficient_resources", lambda config: False
addr, password = gencreds() )
newaddr, newpassword = example_gencreds()
assert not doveauth.create_user(newaddr, newpassword)
assert doveauth.create_user(addr, password)
class TestHttpPost:
@pytest.fixture
def doveauth_server(self, example_config):
server = DoveAuthServer(example_config, port=0)
threading.Thread(target=server.serve_forever, daemon=True).start()
yield f"127.0.0.1:{server.server_address[1]}"
server.shutdown()
server.server_close()
@pytest.fixture
def post(self, doveauth_server):
def post(path, data):
conn = http.client.HTTPConnection(doveauth_server, timeout=10)
try: try:
dictproxy.lookup_passdb(addr, password) return self.post_on(conn, path, data).status
except Exception: finally:
results.put(traceback.format_exc()) conn.close()
else:
results.put(None)
threads = [] return post
for i in range(num_threads):
thread = threading.Thread(target=lookup, daemon=True)
threads.append(thread)
print(f"created {num_threads} threads, starting them and waiting for results") @pytest.fixture
for thread in threads: def connection(self, doveauth_server):
thread.start() """One kept-alive connection, which is all dovecot's HTTP client opens."""
conn = http.client.HTTPConnection(doveauth_server, timeout=10)
yield conn
conn.close()
for i in range(num_threads * req_per_thread): @staticmethod
res = results.get() def post_on(conn, path, data):
if res is not None: conn.request("POST", path, body=data)
pytest.fail(f"concurrent lookup failed\n{res}") resp = conn.getresponse()
resp.read()
return resp
def test_create_and_verify(self, post, example_config, example_gencreds):
addr, password = example_gencreds()
assert post("/create", f"{addr}\t{password}".encode()) == 200
assert stored_hash(example_config, addr).startswith("{SHA512-CRYPT}")
# second login with the same password verifies, a wrong one is refused
assert post("/create", f"{addr}\t{password}".encode()) == 200
assert post("/create", f"{addr}\twrong{password}".encode()) == 403
def test_password_special_chars_survive_transport(self, post, example_gencreds):
addr, _ = example_gencreds()
password = "laksjdlaksjdlak\\sjdlk\"12j'3l1/k2\tj3123"
body = f"{addr}\t{password}".encode()
assert post("/create", body) == 200
assert post("/create", body) == 200
assert post("/create", f"{addr}\totherpassword1".encode()) == 403
def test_password_must_be_utf8(self, post, example_gencreds):
addr, _ = example_gencreds()
assert post("/create", f"{addr}\tpässwort12".encode()) == 200
assert post("/create", addr.encode() + b"\tp\xe4sswort12") == 400
def test_nul_is_refused_before_crypt_sees_it(self, post, example_gencreds):
addr, _ = example_gencreds()
assert post("/create", f"{addr}\tpass\0word12".encode()) == 400
assert (
post("/create", "us\0er12345@chat.example.org\tlongenough1".encode()) == 400
)
def test_refused_creation(self, post, example_gencreds):
addr, _ = example_gencreds()
assert post("/create", f"{addr}\tshort".encode()) == 403
assert post("/create", b"not-an-address\tlongenoughpassword") == 403
body = "bürger123@chat.example.org\tlongenoughpw".encode()
assert post("/create", body) == 403
assert post("/create", b"") == 403
def test_body_length_limit(self, post, example_gencreds):
addr, _ = example_gencreds()
fill = CreateHandler.max_body_len - len(addr) - len("\t")
body = f"{addr}\t{'x' * fill}".encode()
assert len(body) == CreateHandler.max_body_len
assert post("/create", body) == 200
body = f"{addr}\t{'x' * (fill + 1)}".encode()
assert len(body) == CreateHandler.max_body_len + 1
assert post("/create", body) == 400
def test_connection_is_reused_across_200_replies(
self, connection, example_gencreds
):
addr, password = example_gencreds()
body = f"{addr}\t{password}".encode()
# create, then verify the same password, on one connection
for _ in range(2):
resp = self.post_on(connection, "/create", body)
assert (resp.status, resp.will_close) == (200, False)
@pytest.mark.parametrize(
"path,data,status",
[
("/other", b"not read", 404),
("/create", b"x" * (CreateHandler.max_body_len + 1), 400),
("/create", b"not-an-address\tlongenoughpassword", 403),
],
)
def test_error_replies_close_the_connection(self, connection, path, data, status):
resp = self.post_on(connection, path, data)
assert (resp.status, resp.will_close) == (status, True)
@pytest.mark.parametrize("content_length", [None, "-1", "notanumber", "999999"])
def test_bad_content_length(self, connection, content_length):
# the fixture timeout turns a server that waits for the body into a failure
connection.putrequest("POST", "/create", skip_accept_encoding=True)
if content_length is not None:
connection.putheader("Content-Length", content_length)
connection.endheaders()
resp = connection.getresponse()
assert resp.status == 400
assert resp.will_close
+28 -3
View File
@@ -1,14 +1,15 @@
import itertools import itertools
import os import os
import random import random
import shutil
import time import time
from datetime import datetime
from fnmatch import fnmatch from fnmatch import fnmatch
from pathlib import Path from pathlib import Path
import pytest import pytest
from chatmaild.expire import ( from chatmaild.expire import (
Expiry,
FileEntry, FileEntry,
MailboxStat, MailboxStat,
expire_to_target, expire_to_target,
@@ -39,7 +40,7 @@ def fill_mbox(folderdir):
def create_new_messages(basedir, relpaths, size=1000, days=0): def create_new_messages(basedir, relpaths, size=1000, days=0):
now = datetime.utcnow().timestamp() now = time.time()
for relpath in relpaths: for relpath in relpaths:
msg_path = Path(basedir).joinpath(relpath) msg_path = Path(basedir).joinpath(relpath)
@@ -104,6 +105,30 @@ def test_stats_mailbox(mbox1):
assert mbox3.last_login is None assert mbox3.last_login is None
def test_mbox_without_password(mbox1, example_config, capsys):
password = Path(mbox1.basedir).joinpath("password")
os.remove(password)
mbox_rescan = MailboxStat(mbox1.basedir)
assert mbox_rescan.last_login is None
exp = Expiry(example_config, dry=False, now=time.time(), verbose=False)
exp.process_mailbox_stat(mbox_rescan)
out, err = capsys.readouterr()
assert "doesn't have last_login but isn't empty" in err
assert os.path.isdir(mbox_rescan.basedir)
for entry in os.scandir(mbox_rescan.basedir):
if os.path.isdir(entry):
shutil.rmtree(entry)
else:
os.remove(entry)
exp.process_mailbox_stat(mbox_rescan)
out, err = capsys.readouterr()
assert "doesn't have last_login but isn't empty" not in err
assert not os.path.isdir(mbox_rescan.basedir)
def test_report_no_mailboxes(example_config): def test_report_no_mailboxes(example_config):
args = (str(example_config._inipath),) args = (str(example_config._inipath),)
report_main(args) report_main(args)
@@ -124,7 +149,7 @@ def test_report_mdir_filters_by_path(mbox1, example_config):
"""Test that Report with mdir='cur' only counts messages in cur/ subdirectory.""" """Test that Report with mdir='cur' only counts messages in cur/ subdirectory."""
from chatmaild.fsreport import Report from chatmaild.fsreport import Report
now = datetime.utcnow().timestamp() now = time.time()
# Set password mtime to old enough so min_login_age check passes # Set password mtime to old enough so min_login_age check passes
password = Path(mbox1.basedir).joinpath("password") password = Path(mbox1.basedir).joinpath("password")
@@ -1,6 +1,6 @@
import time import time
from chatmaild.doveauth import AuthDictProxy from chatmaild.doveauth import DoveAuth
from chatmaild.lastlogin import ( from chatmaild.lastlogin import (
LastLoginDictProxy, LastLoginDictProxy,
) )
@@ -9,8 +9,8 @@ from chatmaild.lastlogin import (
def test_handle_dovecot_request_last_login(testaddr, example_config): def test_handle_dovecot_request_last_login(testaddr, example_config):
dictproxy = LastLoginDictProxy(config=example_config) dictproxy = LastLoginDictProxy(config=example_config)
authproxy = AuthDictProxy(config=example_config) doveauth = DoveAuth(example_config)
authproxy.lookup_passdb(testaddr, "1l2k3j1l2k3jl123") doveauth.create_user(testaddr, "1l2k3j1l2k3jl123")
dictproxy_transactions = {} dictproxy_transactions = {}
+65 -11
View File
@@ -1,4 +1,5 @@
import io import io
import json
import time import time
import pytest import pytest
@@ -7,6 +8,7 @@ import requests
from chatmaild.metadata import ( from chatmaild.metadata import (
Metadata, Metadata,
MetadataDictProxy, MetadataDictProxy,
read_appversions,
) )
from chatmaild.notifier import ( from chatmaild.notifier import (
Notifier, Notifier,
@@ -324,7 +326,7 @@ def test_turn_credentials_exception_returns_N(notifier, metadata, monkeypatch):
turn_hostname="turn.example.org", turn_hostname="turn.example.org",
) )
def mock_turn_credentials(): def mock_turn_credentials(turn_socket_path):
raise ConnectionRefusedError("socket not available") raise ConnectionRefusedError("socket not available")
monkeypatch.setattr(chatmaild.metadata, "turn_credentials", mock_turn_credentials) monkeypatch.setattr(chatmaild.metadata, "turn_credentials", mock_turn_credentials)
@@ -348,7 +350,9 @@ def test_turn_credentials_success(notifier, metadata, monkeypatch):
turn_hostname="turn.example.org", turn_hostname="turn.example.org",
) )
monkeypatch.setattr(chatmaild.metadata, "turn_credentials", lambda: "user:pass") monkeypatch.setattr(
chatmaild.metadata, "turn_credentials", lambda path: "user:pass"
)
transactions = {} transactions = {}
res = dictproxy.handle_dovecot_request( res = dictproxy.handle_dovecot_request(
@@ -360,15 +364,65 @@ 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_read_appversions(tmp_path):
path = tmp_path.joinpath("appversions.json")
assert read_appversions(path) is None
path.write_text('{\n "clients": []\n}')
assert read_appversions(path) == '{"clients":[]}'
# the value travels as a single dict protocol line
path.write_text('{"clients": [{"clientId": "one\\ntwo"}]}')
assert read_appversions(path) == '{"clients":[{"clientId":"one\\ntwo"}]}'
path.write_text("bad json")
assert read_appversions(path) is None
def test_appversions_lookup(dictproxy):
# the version information shipped with chatmaild is served as a single line
key = b"Lshared/0123/vendor/vendor.dovecot/pvt/server/vendor/deltachat/appversions"
key += b"\tuser@example.org"
rfile, wfile = io.BytesIO(b"H\n" + key), io.BytesIO()
dictproxy.loop_forever(rfile, wfile)
value = wfile.getvalue()
assert value.startswith(b"O") and value.endswith(b"\n")
assert json.loads(value[1:])["clients"]
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
@@ -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
@@ -61,7 +63,7 @@ def test_migration(tmp_path, example_config, caplog):
user = example_config.get_user(path.name) user = example_config.get_user(path.name)
if last_login: if last_login:
assert user.get_last_login_timestamp() == last_login assert user.get_last_login_timestamp() == last_login
assert password == user.get_userdb_dict()["password"] assert password == user.get_password_hash()
assert not all assert not all
assert not example_config.passdb_path.exists() assert not example_config.passdb_path.exists()
+19 -8
View File
@@ -19,25 +19,36 @@ 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_print_new_account(capsys, monkeypatch, maildomain, tmpdir, example_config): 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, 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()
out, err = capsys.readouterr() out, err = capsys.readouterr()
@@ -0,0 +1,45 @@
import shutil
import psutil
from chatmaild.syslimits import has_sufficient_resources
PERMISSIVE = {
"max_load_1m": "99999",
"min_available_memory": "0",
"min_free_disk_space": "0",
}
def test_rejects_constrained_system(make_config, caplog):
assert has_sufficient_resources(make_config("chat.example.org", PERMISSIVE))
for settings in (
{"max_load_1m": "-1.0"},
{"min_available_memory": "99999999G"},
{"min_free_disk_space": "99999999G"},
):
config = make_config("chat.example.org", PERMISSIVE | settings)
caplog.clear()
assert not has_sufficient_resources(config), settings
assert "registration rejected" in caplog.text
def test_unreadable_disk_does_not_reject(make_config, caplog):
config = make_config(
"chat.example.org", PERMISSIVE | {"min_free_disk_space": "99999999G"}
)
shutil.rmtree(config.mailboxes_dir)
assert has_sufficient_resources(config)
assert "ignoring" in caplog.text
def test_one_unreadable_value_keeps_other_checks(make_config, monkeypatch, caplog):
def raise_error(*args):
raise psutil.Error("dud")
monkeypatch.setattr(psutil, "getloadavg", raise_error)
config = make_config(
"chat.example.org", PERMISSIVE | {"min_free_disk_space": "99999999G"}
)
assert not has_sufficient_resources(config)
assert "ignoring" in caplog.text
@@ -0,0 +1,46 @@
import socket
import threading
import pytest
from chatmaild.metadata import turn_credentials
@pytest.fixture
def turn_socket(tmp_path):
sock_path = str(tmp_path / "turn.socket")
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
server.bind(sock_path)
server.listen(1)
yield sock_path, server
server.close()
def test_turn_credentials_timeout(turn_socket):
sock_path, server = turn_socket
with pytest.raises(socket.timeout):
# Inside turn_credentials the kernel listen backlog (1)
# completes connect() without accept()
# so the client blocks on readline() until the 5s timeout fires.
turn_credentials(sock_path)
def test_turn_credentials_connection_refused_on_not_existing_socket(tmp_path):
missing = str(tmp_path / "nonexistent.socket")
with pytest.raises((ConnectionRefusedError, FileNotFoundError)):
turn_credentials(missing)
def test_turn_credentials_socket_success(turn_socket):
sock_path, server = turn_socket
def respond():
conn, _ = server.accept()
conn.sendall(b"testuser:testpass\n")
conn.close()
t = threading.Thread(target=respond, daemon=True)
t.start()
result = turn_credentials(sock_path)
assert result == "testuser:testpass"
@@ -1,73 +0,0 @@
import socket
import threading
import time
from unittest.mock import patch
import pytest
from chatmaild.turnserver import turn_credentials
SOCKET_PATH = "/run/chatmail-turn/turn.socket"
@pytest.fixture
def turn_socket(tmp_path):
"""Create a real Unix socket server at a temp path."""
sock_path = str(tmp_path / "turn.socket")
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
server.bind(sock_path)
server.listen(1)
yield sock_path, server
server.close()
def _call_turn_credentials(sock_path):
"""Call turn_credentials but connect to sock_path instead of hardcoded path."""
original_connect = socket.socket.connect
def patched_connect(self, address):
if address == SOCKET_PATH:
address = sock_path
return original_connect(self, address)
with patch.object(socket.socket, "connect", patched_connect):
return turn_credentials()
def test_turn_credentials_timeout(turn_socket):
"""Server accepts but never responds — must raise socket.timeout."""
sock_path, server = turn_socket
def accept_and_hang():
conn, _ = server.accept()
time.sleep(30)
conn.close()
t = threading.Thread(target=accept_and_hang, daemon=True)
t.start()
with pytest.raises(socket.timeout):
_call_turn_credentials(sock_path)
def test_turn_credentials_connection_refused(tmp_path):
"""Socket file doesn't exist — must raise ConnectionRefusedError or FileNotFoundError."""
missing = str(tmp_path / "nonexistent.socket")
with pytest.raises((ConnectionRefusedError, FileNotFoundError)):
_call_turn_credentials(missing)
def test_turn_credentials_success(turn_socket):
"""Server responds with credentials — must return stripped string."""
sock_path, server = turn_socket
def respond():
conn, _ = server.accept()
conn.sendall(b"testuser:testpass\n")
conn.close()
t = threading.Thread(target=respond, daemon=True)
t.start()
result = _call_turn_credentials(sock_path)
assert result == "testuser:testpass"
+6 -11
View File
@@ -8,28 +8,23 @@ def test_login_timestamp(testaddr, example_config):
assert user.get_last_login_timestamp() == 86400 * 2 assert user.get_last_login_timestamp() == 86400 * 2
def test_get_user_dict_not_set(testaddr, example_config, caplog): def test_get_password_hash_not_set(testaddr, example_config, caplog):
user = example_config.get_user(testaddr) user = example_config.get_user(testaddr)
assert not caplog.records assert not caplog.records
assert user.get_userdb_dict() == {} assert user.get_password_hash() is None
assert len(caplog.records) == 0 assert len(caplog.records) == 0
user.set_password("") user.set_password("")
assert user.get_userdb_dict() == {} assert user.get_password_hash() is None
assert len(caplog.records) == 1 assert len(caplog.records) == 1
def test_get_user_dict(make_config, tmp_path): def test_get_password_hash(make_config, tmp_path):
config = make_config("something.testrun.org") config = make_config("something.testrun.org")
addr = "user1@something.org" user = config.get_user("user1@something.org")
user = config.get_user(addr)
enc_password = "l1k2j31lk2j3l1k23j123" enc_password = "l1k2j31lk2j3l1k23j123"
user.set_password(enc_password) user.set_password(enc_password)
data = user.get_userdb_dict() assert user.get_password_hash() == enc_password
assert addr in str(data["home"])
assert data["uid"] == "vmail"
assert data["gid"] == "vmail"
assert data["password"] == enc_password
def test_no_mailboxes_dir(testaddr, example_config, tmp_path): def test_no_mailboxes_dir(testaddr, example_config, tmp_path):
-10
View File
@@ -1,10 +0,0 @@
#!/usr/bin/env python3
import socket
def turn_credentials() -> str:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client_socket:
client_socket.settimeout(5)
client_socket.connect("/run/chatmail-turn/turn.socket")
with client_socket.makefile("rb") as file:
return file.readline().decode("utf-8").strip()
+6 -9
View File
@@ -21,20 +21,17 @@ class User:
def can_track(self): def can_track(self):
return "@" in self.addr return "@" in self.addr
def get_userdb_dict(self): def get_password_hash(self):
"""Return a non-empty dovecot 'userdb' style dict
if the user has an existing non-empty password"""
try: try:
pw = self.password_path.read_text() passhash = self.password_path.read_text()
except FileNotFoundError: except FileNotFoundError:
return {} return None
if not pw: if not passhash:
logging.error(f"password is empty for: {self.addr}") logging.error(f"password is empty for: {self.addr}")
return {} return None
home = str(self.maildir) return passhash
return dict(addr=self.addr, home=home, uid=self.uid, gid=self.gid, password=pw)
def is_incoming_cleartext_ok(self): def is_incoming_cleartext_ok(self):
return not self.enforce_E2EE_path.exists() return not self.enforce_E2EE_path.exists()
+2 -4
View File
@@ -19,6 +19,8 @@ dependencies = [
"pytest-xdist", "pytest-xdist",
"execnet", "execnet",
"imap_tools", "imap_tools",
"jinja2",
"lupa",
"deltachat-rpc-client", "deltachat-rpc-client",
"deltachat-rpc-server", "deltachat-rpc-server",
] ]
@@ -26,10 +28,6 @@ dependencies = [
[project.scripts] [project.scripts]
cmdeploy = "cmdeploy.cmdeploy:main" cmdeploy = "cmdeploy.cmdeploy:main"
[project.entry-points.pytest11]
"chatmaild.testplugin" = "chatmaild.tests.plugin"
"cmdeploy.testplugin" = "cmdeploy.tests.plugin"
[tool.pytest.ini_options] [tool.pytest.ini_options]
addopts = "-v -ra --strict-markers" addopts = "-v -ra --strict-markers"
+1
View File
@@ -0,0 +1 @@
+24 -103
View File
@@ -1,17 +1,14 @@
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
class AcmetoolDeployer(Deployer): class AcmetoolDeployer(Deployer):
bin_path = "/usr/bin/acmetool"
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,123 +16,47 @@ 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(
src=importlib.resources.files(__package__).joinpath( "acmetool/acmetool-redirector.service.j2", bin_path=self.bin_path
"acmetool-redirector.service"
),
dest="/etc/systemd/system/acmetool-redirector.service",
user="root",
group="root",
mode="644",
) )
self.need_restart_redirector = service_file.changed self.ensure_systemd_unit(
"acmetool/acmetool-reconcile.service.j2", bin_path=self.bin_path
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 self.ensure_systemd_unit("acmetool/acmetool-reconcile.timer")
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)}",
commands=["acmetool --batch --xlog.severity=debug reconcile"], commands=[f"{self.bin_path} --batch --xlog.severity=debug reconcile"],
) )
@@ -4,5 +4,5 @@ After=network.target
[Service] [Service]
Type=oneshot Type=oneshot
ExecStart=/usr/bin/acmetool --batch reconcile ExecStart={{ bin_path }} --batch reconcile
@@ -3,7 +3,7 @@ Description=acmetool HTTP redirector
[Service] [Service]
Type=notify Type=notify
ExecStart=/usr/bin/acmetool redirector --service.uid=daemon --bind=127.0.0.1:402 ExecStart={{ bin_path }} redirector --service.uid=daemon --bind=127.0.0.1:402
Restart=always Restart=always
RestartSec=30 RestartSec=30
@@ -1,2 +1,2 @@
"acme-enter-email": "{{ email }}" "acme-enter-email": "{{ email }}"
"acme-agreement:https://letsencrypt.org/documents/LE-SA-v1.6-August-18-2025.pdf": true "acme-agreement:https://letsencrypt.org/documents/LE-SA-v1.8-July-06-2026.pdf": true
+119 -14
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, **kwargs) -> 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:
@@ -63,6 +63,7 @@ def configure_remote_units(mail_domain, units) -> None:
config_path=remote_chatmail_inipath, config_path=remote_chatmail_inipath,
remote_venv_dir=remote_venv_dir, remote_venv_dir=remote_venv_dir,
mail_domain=mail_domain, mail_domain=mail_domain,
**kwargs,
) )
basename = fn if "." in fn else f"{fn}.service" basename = fn if "." in fn else f"{fn}.service"
@@ -70,15 +71,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 +87,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 +134,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 +144,114 @@ 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", **kwargs):
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,
**kwargs,
)
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, mode="755"):
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 {mode} {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
+20 -2
View File
@@ -84,13 +84,24 @@ def run_cmd_options(parser):
add_ssh_host_option(parser) add_ssh_host_option(parser)
def _warn_unused_settings(unused_keys, out):
if unused_keys:
names = ", ".join(unused_keys)
out.red(
f"WARNING: chatmail.ini contains settings that have no effect: {names}\n"
"Please remove them from chatmail.ini."
)
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,8 +130,11 @@ 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.")
_warn_unused_settings(args.config._unused_keys, out)
return 0 return 0
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
out.red("Deploy failed") out.red("Deploy failed")
@@ -140,6 +154,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 +195,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}")
+132 -207
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,
) )
@@ -35,6 +33,7 @@ from .filtermail.deployer import FiltermailDeployer
from .mtail.deployer import MtailDeployer from .mtail.deployer import MtailDeployer
from .nginx.deployer import NginxDeployer from .nginx.deployer import NginxDeployer
from .opendkim.deployer import OpendkimDeployer from .opendkim.deployer import OpendkimDeployer
from .pins import IROH_ARTIFACTS, TURN_ARTIFACTS
from .postfix.deployer import PostfixDeployer from .postfix.deployer import PostfixDeployer
from .selfsigned.deployer import SelfSignedTlsDeployer from .selfsigned.deployer import SelfSignedTlsDeployer
from .www import build_webpages, find_merge_conflict, get_paths from .www import build_webpages, find_merge_conflict, get_paths
@@ -82,25 +81,39 @@ 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,
# Remove venv if its Python major.minor doesn't match the system Python
server.shell(
name="remove stale chatmaild venv if python version changed",
commands=[
"\n".join(
[
r"re='[0-9]+\.[0-9]+'", # major.minor out of 'Python X.Y.Z'
'sys_version=$(python3 --version 2>/dev/null | grep -oE "$re")',
f'venv_version=$({remote_venv_dir}/bin/python --version 2>/dev/null | grep -oE "$re")',
# an empty sys_version means we could not tell: keep the venv
f'[ -z "$sys_version" ] || [ "$sys_version" = "$venv_version" ] '
f"|| rm -rf {remote_venv_dir}",
]
)
],
) )
pip.virtualenv( pip.virtualenv(
@@ -122,40 +135,43 @@ 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,
# it causes subsequent steps to fail to resolve hosts. # it causes subsequent steps to fail to resolve hosts.
with blocked_service_startup(): with blocked_service_startup():
# dns-root-data is an optional package
# that contains /usr/share/dns/root.key
#
# This file is copied into /var/lib/unbound/root.key
# at the start of "unbound" systemd unit
# by /usr/libexec/unbound-helper shell script
# from the "unbound" package as of version 1.17.1-2+deb12u4
#
# The same /var/lib/unbound/root.key can be retrieved directly
# following the procedure from
# <https://www.rfc-editor.org/info/rfc7958/#section-3.1>
# with "unbound-anchor -a /var/lib/unbound/root.key"
# We don't install and use "unbound-anchor".
apt.packages( apt.packages(
name="Install unbound", name="Install unbound",
packages=["unbound", "unbound-anchor", "dnsutils"], packages=["unbound", "dns-root-data", "dnsutils"],
) )
def configure(self): def configure(self):
@@ -176,41 +192,19 @@ 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", force=True,
group="root",
mode="644",
) )
server.shell( self.ensure_directory(
name="Generate root keys for validating DNSSEC", path="/etc/unbound/unbound.conf.d",
commands=[ )
"unbound-anchor -a /var/lib/unbound/root.key || true", self.put_template(
], "unbound/unbound.conf.j2",
"/etc/unbound/unbound.conf.d/chatmail.conf",
disable_ipv6=self.config.disable_ipv6,
) )
if self.config.disable_ipv6:
files.directory(
path="/etc/unbound/unbound.conf.d",
present=True,
user="root",
group="root",
mode="755",
)
conf = files.put(
src=get_resource("unbound/unbound.conf.j2"),
dest="/etc/unbound/unbound.conf.d/chatmail.conf",
user="root",
group="root",
mode="644",
)
else:
conf = files.file(
path="/etc/unbound/unbound.conf.d/chatmail.conf",
present=False,
)
self.need_restart |= conf.changed
def activate(self): def activate(self):
server.shell( server.shell(
@@ -220,27 +214,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 +243,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)
@@ -286,17 +271,20 @@ class LegacyRemoveDeployer(Deployer):
def install(self): def install(self):
apt.packages(name="Remove rspamd", packages="rspamd", present=False) apt.packages(name="Remove rspamd", packages="rspamd", present=False)
# unbound-anchor was used to download /var/lib/unbound/root.key
# It is replaced by dns-root-data which contains /usr/share/dns/root.key.
# unbound systemd unit copies /usr/share/dns/root.key
# into /var/lib/unbound/root.key automatically on start
# as long as /usr/share/dns/root.key is present.
apt.packages(name="Remove unbound-anchor", packages="unbound-anchor", present=False)
# 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 +292,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(
@@ -335,126 +319,70 @@ def check_config(config):
class TurnDeployer(Deployer): class TurnDeployer(Deployer):
bin_path = "/usr/local/bin/chatmail-turn"
def __init__(self, mail_domain): def __init__(self, mail_domain):
self.mail_domain = mail_domain self.mail_domain = mail_domain
self.units = ["turnserver"] self.units = ["turnserver"]
def install(self): def install(self):
(url, sha256sum) = { (url, sha256sum) = TURN_ARTIFACTS[host.get_fact(facts.server.Arch)]
"x86_64": ( self.download_executable(url, self.bin_path, sha256sum)
"https://github.com/chatmail/chatmail-turn/releases/download/v0.4/chatmail-turn-x86_64-linux",
"1ec1f5c50122165e858a5a91bcba9037a28aa8cb8b64b8db570aa457c6141a8a",
),
"aarch64": (
"https://github.com/chatmail/chatmail-turn/releases/download/v0.4/chatmail-turn-aarch64-linux",
"0fb3e792419494e21ecad536464929dba706bb2c88884ed8f1788141d26fc756",
),
}[host.get_fact(facts.server.Arch)]
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, bin_path=self.bin_path
)
def activate(self): def activate(self):
activate_remote_units(self.units) activate_remote_units(self, self.units)
class IrohDeployer(Deployer): class IrohDeployer(Deployer):
bin_path = "/usr/local/bin/iroh-relay"
config_path = "/etc/iroh-relay.toml"
def __init__(self, enable_iroh_relay): def __init__(self, enable_iroh_relay):
self.enable_iroh_relay = enable_iroh_relay self.enable_iroh_relay = enable_iroh_relay
def install(self): def install(self):
(url, sha256sum) = { (url, sha256sum) = IROH_ARTIFACTS[host.get_fact(facts.server.Arch)]
"x86_64": ( self.download_executable(
"https://github.com/n0-computer/iroh/releases/download/v0.35.0/iroh-relay-v0.35.0-x86_64-unknown-linux-musl.tar.gz", url,
"45c81199dbd70f8c4c30fef7f3b9727ca6e3cea8f2831333eeaf8aa71bf0fac1", self.bin_path,
), sha256sum,
"aarch64": ( extract="gunzip | tar -xf - ./iroh-relay -O",
"https://github.com/n0-computer/iroh/releases/download/v0.35.0/iroh-relay-v0.35.0-aarch64-unknown-linux-musl.tar.gz", )
"f8ef27631fac213b3ef668d02acd5b3e215292746a3fc71d90c63115446008b1",
),
}[host.get_fact(facts.server.Arch)]
existing_sha256sum = host.get_fact(Sha256File, "/usr/local/bin/iroh-relay")
if existing_sha256sum != sha256sum:
server.shell(
name="Download iroh-relay",
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(
name="Upload iroh-relay systemd unit", "iroh-relay.service.j2",
src=get_resource("iroh-relay.service"), bin_path=self.bin_path,
dest="/etc/systemd/system/iroh-relay.service", config_path=self.config_path,
user="root",
group="root",
mode="644",
) )
self.need_restart |= systemd_unit.changed self.put_file("iroh-relay.toml", self.config_path)
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):
def __init__(self, config): def __init__(self, config):
self.config = config self.config = config
self.units = ( self.units = (
# doveauth must restart when chatmaild/ini file changes
"doveauth",
"chatmail-metadata", "chatmail-metadata",
"lastlogin", "lastlogin",
"chatmail-expire", "chatmail-expire",
@@ -464,14 +392,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 +413,15 @@ 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", # Pin dovecot-* to priority -1 before any apt operation, apt should
mode="644", # never manage dovecot as our version might be lower than the distro's.
self.put_file(
src=StringIO("Package: dovecot-*\nPin: version *\nPin-Priority: -1\n"),
dest="/etc/apt/preferences.d/pin-dovecot",
) )
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 +438,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 +462,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 +475,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 +501,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():
@@ -614,15 +537,17 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
("nginx", 443), ("nginx", 443),
(["master", "smtpd"], 465), (["master", "smtpd"], 465),
(["master", "smtpd"], 587), (["master", "smtpd"], 587),
(["imap-login", "dovecot"], 993), (["dovecot", "imap-login"], 993),
("iroh-relay", 3340), ("iroh-relay", 3340),
("mtail", 3903), ("mtail", 3903),
("stats", 3904), (["dovecot", "stats"], 3904),
("nginx", 8443), ("nginx", 8443),
(["master", "smtpd"], config.postfix_reinject_port), (["master", "smtpd"], config.postfix_reinject_port),
(["master", "smtpd"], config.postfix_reinject_port_incoming), (["master", "smtpd"], config.postfix_reinject_port_incoming),
("filtermail", config.filtermail_smtp_port), ("filtermail", config.filtermail_smtp_port),
("filtermail", config.filtermail_smtp_port_incoming), ("filtermail", config.filtermail_smtp_port_incoming),
("filtermail", config.filtermail_http_port_incoming),
("filtermail", config.filtermail_lmtp_port_transport),
] ]
for service, port in port_services: for service, port in port_services:
print(f"Checking if port {port} is available for {service}...") print(f"Checking if port {port} is available for {service}...")
@@ -635,7 +560,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 +568,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.
-12
View File
@@ -1,12 +0,0 @@
uri = proxy:/run/doveauth/doveauth.socket:auth
iterate_disable = no
iterate_prefix = userdb/
default_pass_scheme = plain
# %E escapes characters " (double quote), ' (single quote) and \ (backslash) with \ (backslash).
# See <https://doc.dovecot.org/2.3/configuration_manual/config_file/config_variables/#modifiers>
# for documentation.
#
# We escape user-provided input and use double quote as a separator.
password_key = passdb/%Ew"%Eu
user_key = userdb/%Eu
+68
View File
@@ -0,0 +1,68 @@
-- Existing addresses are served from the maildir directly.
-- Unknown ones are offered to doveauth, which owns the creation policy.
local mailboxes_dir = "{{ config.mailboxes_dir }}"
local domain_suffix = "@{{ config.mail_domain }}"
local create_url = "http://127.0.0.1:{{ config.doveauth_http_port }}/create"
local http_client
local function is_ours(user)
return user:sub(-#domain_suffix) == domain_suffix
and not user:find("/", 1, true)
end
local function password_hash(user)
local fh = io.open(mailboxes_dir .. "/" .. user .. "/password", "r")
if not fh then
return nil
end
local hash, rest = fh:read("l", "a")
fh:close()
if hash == nil or hash == "" or rest ~= "" then
return nil
end
return hash
end
local function userdb_fields(user)
return {home = mailboxes_dir .. "/" .. user, uid = "vmail", gid = "vmail"}
end
local function create(user, password)
local request = http_client:request({url = create_url, method = "POST"})
request:set_payload(user .. "\t" .. password)
return request:submit():status()
end
-- Entry points called by dovecot
function script_init()
http_client = dovecot.http.client({request_timeout_msecs = 5000, max_attempts = 1})
return 0
end
function auth_userdb_lookup(req)
if not is_ours(req.user) or password_hash(req.user) == nil then
return dovecot.auth.USERDB_RESULT_USER_UNKNOWN, {}
end
return dovecot.auth.USERDB_RESULT_OK, userdb_fields(req.user)
end
function auth_password_verify(req, password)
if not is_ours(req.user) then
return dovecot.auth.PASSDB_RESULT_USER_UNKNOWN, {}
end
local hash = password_hash(req.user)
if hash == nil then
-- doveauth refuses with 4xx; dovecot reports its own failures as 9000 and up
local status = create(req.user, password)
if status >= 500 then
return dovecot.auth.PASSDB_RESULT_INTERNAL_FAILURE, {}
elseif status ~= 200 then
return dovecot.auth.PASSDB_RESULT_USER_UNKNOWN, {}
end
elseif req:password_verify(hash, password) ~= 1 then
return dovecot.auth.PASSDB_RESULT_PASSWORD_MISMATCH, {}
end
return dovecot.auth.PASSDB_RESULT_OK, userdb_fields(req.user)
end
+71 -97
View File
@@ -1,32 +1,27 @@
import io
import urllib.request import urllib.request
from chatmaild.config import Config 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 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,
) )
from cmdeploy.pins import DOVECOT_SHA256, DOVECOT_VERSION
DOVECOT_ARCHIVE_VERSION = "2.3.21+dfsg1-3" VERSION_ID_CMD = "grep '^VERSION_ID=' /etc/os-release"
DOVECOT_PACKAGE_VERSION = f"1:{DOVECOT_ARCHIVE_VERSION}"
DOVECOT_SHA256 = {
("core", "amd64"): "dd060706f52a306fa863d874717210b9fe10536c824afe1790eec247ded5b27d", def _stamped_version(deb_release: int) -> str:
("core", "arm64"): "e7548e8a82929722e973629ecc40fcfa886894cef3db88f23535149e7f730dc9", """Version as built, including the per-distro suffix stamped by
("imapd", "amd64"): "8d8dc6fc00bbb6cdb25d345844f41ce2f1c53f764b79a838eb2a03103eebfa86", chatmail/dovecot CI into package version and filename."""
("imapd", "arm64"): "178fa877ddd5df9930e8308b518f4b07df10e759050725f8217a0c1fb3fd707f", return f"{DOVECOT_VERSION}+deb{deb_release}u1"
("lmtpd", "amd64"): "2f69ba5e35363de50962d42cccbfe4ed8495265044e244007d7ccddad77513ab",
("lmtpd", "arm64"): "89f52fb36524f5877a177dff4a713ba771fd3f91f22ed0af7238d495e143b38f",
}
class DovecotDeployer(Deployer): class DovecotDeployer(Deployer):
@@ -35,75 +30,68 @@ class DovecotDeployer(Deployer):
def __init__(self, config, disable_mail): def __init__(self, config, disable_mail):
self.config = config self.config = config
self.disable_mail = disable_mail self.disable_mail = disable_mail
self.units = ["doveauth"] self.units = []
def install(self): def install(self):
arch = host.get_fact(Arch) arch = host.get_fact(Arch)
deb_release = _parse_version_id(host.get_fact(Command, VERSION_ID_CMD))
with blocked_service_startup(): with blocked_service_startup():
debs = [] debs = []
for pkg in ("core", "imapd", "lmtpd"): for pkg in ("core", "imapd", "lmtpd", "auth-lua"):
deb, changed = _download_dovecot_package(pkg, arch) deb, changed = _download_dovecot_package(pkg, arch, deb_release)
self.need_restart |= changed self.need_restart |= changed
if deb: if deb:
debs.append(deb) debs.append(deb)
if debs: if debs:
deb_list = " ".join(debs) deb_list = " ".join(debs)
# First dpkg may fail on missing dependencies (stderr suppressed); # apt-get install with local .deb paths resolves depends
# apt-get --fix-broken pulls them in, then dpkg retries cleanly. # against the configured repos (e.g. pulls libwrap0),
# The pin file written earlier by ChatmailDeployer prevents apt
# from installing a 'wrong' version
server.shell( server.shell(
name="Install dovecot packages", name="Install dovecot packages",
commands=[ commands=[
f"dpkg --force-confdef --force-confold -i {deb_list} 2> /dev/null || true", "DEBIAN_FRONTEND=noninteractive apt-get install -y "
"DEBIAN_FRONTEND=noninteractive apt-get -y --fix-broken install", '-o Dpkg::Options::="--force-confdef" '
f"dpkg --force-confdef --force-confold -i {deb_list}", '-o Dpkg::Options::="--force-confold" '
f"--allow-downgrades {deb_list}",
], ],
) )
self.need_restart = True self.need_restart = True
files.put(
name="Pin dovecot packages to block Debian dist-upgrades",
src=io.StringIO(
"Package: dovecot-*\n"
"Pin: version *\n"
"Pin-Priority: -1\n"
),
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:
stale = host.get_fact( stale = host.get_fact(
Command, Command,
'pid=$(systemctl show -p MainPID --value dovecot.service 2>/dev/null);' "pid=$(systemctl show -p MainPID --value dovecot.service 2>/dev/null);"
' [ "${pid:-0}" != "0" ] && readlink "/proc/$pid/exe" 2>/dev/null | grep -q "(deleted)"' ' [ "${pid:-0}" != "0" ] && readlink "/proc/$pid/exe" 2>/dev/null | grep -q "(deleted)"'
" && echo STALE || true", " && echo STALE || true",
) )
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 _parse_version_id(version_line: str) -> int:
"""Debian major release from an /etc/os-release VERSION_ID line."""
_, _, raw = (version_line or "").strip().partition("=")
try:
return int(raw.strip('"'))
except ValueError:
raise ValueError(f"cannot determine Debian release from {version_line!r}")
def _pick_url(primary, fallback): def _pick_url(primary, fallback):
@@ -115,27 +103,36 @@ def _pick_url(primary, fallback):
return fallback return fallback
def _download_dovecot_package(package: str, arch: str) -> tuple[str | None, bool]: def _download_dovecot_package(package: str, arch: str, deb_release: int) -> tuple[str | None, bool]:
"""Download a dovecot .deb if needed, return (path, changed).""" """Download a dovecot .deb if needed, return (path, changed)."""
arch = "amd64" if arch == "x86_64" else arch arch = "amd64" if arch == "x86_64" else arch
arch = "arm64" if arch == "aarch64" else arch arch = "arm64" if arch == "aarch64" else arch
pkg_name = f"dovecot-{package}" pkg_name = f"dovecot-{package}"
sha256 = DOVECOT_SHA256.get((package, arch)) try:
if sha256 is None: # never fall back to the distro package: it is pinned to -1 and would
op = apt.packages(packages=[pkg_name]) # in any case be a version we did not build and do not support
return None, bool(getattr(op, "changed", False)) sha256 = DOVECOT_SHA256[(arch, deb_release, package)]
except KeyError:
raise ValueError(f"no dovecot build for {pkg_name} on deb{deb_release}/{arch}")
stamped_version = _stamped_version(deb_release)
installed_versions = host.get_fact(DebPackages).get(pkg_name, []) installed_versions = host.get_fact(DebPackages).get(pkg_name, [])
if DOVECOT_PACKAGE_VERSION in installed_versions: if f"1:{stamped_version}" in installed_versions:
return None, False return None, False
url_version = DOVECOT_ARCHIVE_VERSION.replace("+", "%2B") # Primary URL: flat structure with distro suffix in filename
deb_base = f"{pkg_name}_{url_version}_{arch}.deb" primary_deb = f"{pkg_name}_{stamped_version}_{arch}.deb"
primary_url = f"https://download.delta.chat/dovecot/{deb_base}" primary_url = f"https://download.delta.chat/dovecot/{primary_deb}"
fallback_url = f"https://github.com/chatmail/dovecot/releases/download/upstream%2F{url_version}/{deb_base}" # GitHub release files: escaped + in filename; the release tag stays
# distro-neutral, both distros ship in one combined release
tag_version = DOVECOT_VERSION.replace("+", "%2B")
fallback_deb = f"{pkg_name}_{stamped_version.replace('+', '%2B')}_{arch}.deb"
fallback_url = (
f"https://github.com/chatmail/dovecot/releases/download/upstream%2F{tag_version}/{fallback_deb}"
)
url = _pick_url(primary_url, fallback_url) url = _pick_url(primary_url, fallback_url)
deb_filename = f"/root/{deb_base}" deb_filename = f"/root/{primary_deb}"
files.download( files.download(
name=f"Download {pkg_name}", name=f"Download {pkg_name}",
@@ -148,38 +145,20 @@ def _download_dovecot_package(package: str, arch: str) -> tuple[str | None, bool
return deb_filename, True return deb_filename, True
def _configure_dovecot(config: Config, debug: bool = False) -> tuple[bool, bool]: def _configure_dovecot(deployer, config: Config, debug: bool = False):
"""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_template("dovecot/auth.lua.j2", "/etc/dovecot/auth.lua", config=config)
auth_config = files.put( deployer.remove_file("/etc/dovecot/auth.conf")
src=get_resource("dovecot/auth.conf"), deployer.put_file(
dest="/etc/dovecot/auth.conf", "dovecot/push_notification.lua", "/etc/dovecot/push_notification.lua"
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 +182,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
+12 -7
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
@@ -36,11 +37,15 @@ default_client_limit = 20000
# the following warning will be logged: # the following warning will be logged:
# Warning: service(imap): process_limit (1024) reached, client connections are being dropped # Warning: service(imap): process_limit (1024) reached, client connections are being dropped
service imap { service imap {
process_limit = 50000 process_limit = {{ config.max_imap_connections }}
} }
mail_server_admin = mailto:root@{{ config.mail_domain }} {% if config.privacy_mail %}
mail_server_comment = Chatmail server # Advertised to clients as IMAP METADATA /shared/admin (RFC 5464).
# The privacy_mail contact from chatmail.ini is used because it is
# the only address set by an operator.
mail_server_admin = mailto:{{ config.privacy_mail }}
{% endif %}
# `zlib` enables compressing messages stored in the maildir. # `zlib` enables compressing messages stored in the maildir.
# See # See
@@ -56,12 +61,12 @@ imap_capability = +XDELTAPUSH XCHATMAIL
# Authentication for system users. # Authentication for system users.
passdb { passdb {
driver = dict driver = lua
args = /etc/dovecot/auth.conf args = file=/etc/dovecot/auth.lua blocking=yes
} }
userdb { userdb {
driver = dict driver = lua
args = /etc/dovecot/auth.conf args = file=/etc/dovecot/auth.lua blocking=yes
} }
## ##
## Mailbox locations and namespaces ## Mailbox locations and namespaces
+12 -38
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,
)
@@ -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
+20 -35
View File
@@ -1,52 +1,37 @@
from pyinfra import facts, host import os
from pyinfra.operations import files, systemd
from cmdeploy.basedeploy import Deployer, get_resource from pyinfra import facts, host
from cmdeploy.basedeploy import Deployer
from cmdeploy.pins import FILTERMAIL_ARTIFACTS
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")
if local_bin:
self.put_executable(
src=local_bin,
dest=self.bin_path,
)
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, sha256sum = FILTERMAIL_ARTIFACTS[arch]
sha256sum = { self.download_executable(url, self.bin_path, sha256sum)
"x86_64": "48b3fb80c092d00b9b0a0ef77a8673496da3b9aed5ec1851e1df936d5589d62f",
"aarch64": "c65bd5f45df187d3d65d6965a285583a3be0f44a6916ff12909ff9a8d702c22e",
}[arch]
self.need_restart |= files.download(
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
@@ -0,0 +1,12 @@
[Unit]
Description=Chatmail transport service
[Service]
ExecStart={{ bin_path }} {{ config_path }} transport
Restart=always
RestartSec=30
User=vmail
LimitNOFILE=524288
[Install]
WantedBy=multi-user.target
@@ -2,7 +2,7 @@
Description=Iroh relay Description=Iroh relay
[Service] [Service]
ExecStart=/usr/local/bin/iroh-relay --config-path /etc/iroh-relay.toml ExecStart={{ bin_path }} --config-path {{ config_path }}
Restart=on-failure Restart=on-failure
RestartSec=5s RestartSec=5s
User=iroh User=iroh
@@ -28,6 +28,13 @@ counter created_nonci_accounts
} }
} }
# doveauth refusing new addresses because a chatmail.ini
# system resource limit is exceeded.
counter rejected_registrations
/registration rejected: / {
rejected_registrations++
}
counter postfix_timeouts counter postfix_timeouts
/timeout after DATA/ { /timeout after DATA/ {
postfix_timeouts++ postfix_timeouts++
@@ -73,6 +80,11 @@ counter incoming_unencrypted_mail_count
filtered_incoming_mail_count++ filtered_incoming_mail_count++
} }
counter incoming_mailer_daemon_mail_count
/Incoming: Filtering mailer-daemon message from/ {
incoming_mailer_daemon_mail_count++
filtered_incoming_mail_count++
}
counter rejected_unencrypted_mail_count counter rejected_unencrypted_mail_count
/Rejected unencrypted mail/ { /Rejected unencrypted mail/ {
+40 -46
View File
@@ -1,13 +1,14 @@
from pyinfra import facts, host from pyinfra import facts, host
from pyinfra.operations import apt, files, server, systemd from pyinfra.operations import apt, server
from cmdeploy.basedeploy import ( from cmdeploy.basedeploy import Deployer
Deployer, from cmdeploy.pins import FILTERMAIL_ARTIFACTS, MTAIL_ARTIFACTS
get_resource,
)
class MtailDeployer(Deployer): class MtailDeployer(Deployer):
bin_path = "/usr/local/bin/mtail"
progs_dir = "/etc/mtail"
def __init__(self, mtail_address): def __init__(self, mtail_address):
self.mtail_address = mtail_address self.mtail_address = mtail_address
@@ -15,54 +16,47 @@ class MtailDeployer(Deployer):
# Uninstall mtail package to install a static binary. # Uninstall mtail package to install a static binary.
apt.packages(name="Uninstall mtail", packages=["mtail"], present=False) apt.packages(name="Uninstall mtail", packages=["mtail"], present=False)
(url, sha256sum) = { (url, sha256sum) = MTAIL_ARTIFACTS[host.get_fact(facts.server.Arch)]
"x86_64": ( self.download_executable(
"https://github.com/google/mtail/releases/download/v3.0.8/mtail_3.0.8_linux_amd64.tar.gz", url,
"123c2ee5f48c3eff12ebccee38befd2233d715da736000ccde49e3d5607724e4", self.bin_path,
), sha256sum,
"aarch64": ( extract="gunzip | tar -xf - mtail -O",
"https://github.com/google/mtail/releases/download/v3.0.8/mtail_3.0.8_linux_arm64.tar.gz",
"aa04811c0929b6754408676de520e050c45dddeb3401881888a092c9aea89cae",
),
}[host.get_fact(facts.server.Arch)]
server.shell(
name="Download mtail",
commands=[
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)",
"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,
bin_path=self.bin_path,
progs_dir=self.progs_dir,
) )
if self.mtail_address:
mtail_conf = files.put( self.put_file(
name="Mtail configuration", "mtail/delivered_mail.mtail", f"{self.progs_dir}/delivered_mail.mtail"
src=get_resource("mtail/delivered_mail.mtail"), )
dest="/etc/mtail/delivered_mail.mtail", url, sha256sum = FILTERMAIL_ARTIFACTS['mtail']
user="root", self.download_executable(
group="root", url,
mode="644", f"{self.progs_dir}/filtermail.mtail",
) sha256sum,
self.need_restart = mtail_conf.changed mode="644",
)
if self.need_restart:
# Check if all installed mtail rules compile or fail early
# --one_shot to exit, --port 0 to not clash with running mtail.
server.shell(
name="Validate mtail programs",
commands=[
f"timeout 30 {self.bin_path} --compile_only --one_shot"
f" --progs {self.progs_dir} --logs /dev/null"
" --address 127.0.0.1 --port 0"
],
)
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
+4 -1
View File
@@ -1,10 +1,13 @@
[Unit] [Unit]
Description=mtail Description=mtail
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 | {{ bin_path }} --address={{ address }} --port={{ port }} --progs {{ progs_dir }} --logtostderr --logs -"
Restart=on-failure Restart=on-failure
RestartSec=2s
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
+17 -54
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
+9 -6
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 }};
} }
@@ -127,7 +128,10 @@ http {
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";
} }
location /relay/probe { # Endpoints Iroh uses for net_report probes and that clients
# probe to tell whether this relay works. Both paths are served
# by iroh-relay 0.35 and by the 1.0 line.
location /ping {
proxy_pass http://127.0.0.1:3340; proxy_pass http://127.0.0.1:3340;
proxy_http_version 1.1; proxy_http_version 1.1;
} }
@@ -143,7 +147,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 {
+25 -63
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
+73
View File
@@ -0,0 +1,73 @@
"""Versions, hashes, and download URLs for pre-built artifacts fetched during deploy."""
FILTERMAIL_VERSION = "v0.7.4"
FILTERMAIL_ARTIFACTS = {
"x86_64": (
f"https://github.com/chatmail/filtermail/releases/download/{FILTERMAIL_VERSION}/filtermail-x86_64",
"484cb8dff083134aefba9fce4a6b7ef4784a0f0e28e5108ecf8bb9e58a44fd2c",
),
"aarch64": (
f"https://github.com/chatmail/filtermail/releases/download/{FILTERMAIL_VERSION}/filtermail-aarch64",
"66aa0ca2ca9add7a12d92883d76f8786384092adfde24a3d3a1d0b1f30d23a9e",
),
"mtail": (
f"https://raw.githubusercontent.com/chatmail/filtermail/{FILTERMAIL_VERSION}/contrib/filtermail.mtail",
"948f688bb89ad47e6eb0fc8fa107e201a689f5adc264ff926be487a2a8562b51",
),
}
MTAIL_VERSION = "3.4.9"
MTAIL_ARTIFACTS = {
"x86_64": (
f"https://github.com/jaqx0r/mtail/releases/download/v{MTAIL_VERSION}/mtail_{MTAIL_VERSION}_linux_amd64.tar.gz",
"55f64a87f71955bb871c724b4aadf19fe9d854e6327196919c7fe44943427eab",
),
"aarch64": (
f"https://github.com/jaqx0r/mtail/releases/download/v{MTAIL_VERSION}/mtail_{MTAIL_VERSION}_linux_arm64.tar.gz",
"e0a2b66b372ca257d7daeb7ba10f9233a2192a1f9057618fccc6be5c854a2a3c",
),
}
# distro-neutral base version, as committed in chatmail/dovecot debian/changelog
DOVECOT_VERSION = "2.3.21+dfsg1-3+chatmail2"
DOVECOT_SHA256 = {
("amd64", 12, "auth-lua"): "ef1b8e1db45147a74b48d63125bd61b2cc2f250e1006656ba1c58b9c12f5cde6",
("arm64", 12, "auth-lua"): "c1a06ee9374439893e397ba3b0cacf532a733290c184f4f30ea39df8699be329",
("amd64", 13, "auth-lua"): "6c0946d2516efcbcaa09a27df9b8ea701861cce270d71941056b3c69831a5ea2",
("arm64", 13, "auth-lua"): "5e6c9cfe47f7f3b8aa0d68a3e607161b6abaee1ad696cb1419e997b3099b2985",
("amd64", 12, "core"): "ac3977264d9b9a6fcec53fd3f5cdd2a79ca8aa0324de530c07e535008540826e",
("arm64", 12, "core"): "21626c9c9b52cbdcf1a17b5c09e3c4043e69aa371bf83cc2fcb3b7ddaecdc109",
("amd64", 13, "core"): "47c242ef23c17e700ac19d52d82c9fdb2ebd757d8beb3a7f6781d2de59f87bd0",
("arm64", 13, "core"): "c14c53f112c875f698c4cb6e5870c605cd0a9dd98d35a66e94ceb1827f8020a3",
("amd64", 12, "imapd"): "92a7ab5fc7dc32886a0c34404f919f1335d397b48c467e0c1ef77e56978f60ea",
("arm64", 12, "imapd"): "9369fd566fec4df109ef23debf34ea0417ae85beb29cbe7de619d4d1f31b120c",
("amd64", 13, "imapd"): "e38cc1266455f937ed62f971ea859c47e1a99247841ed0ad946963b524cfdbc5",
("arm64", 13, "imapd"): "11d97dabf23171b37f8b1335dfdb81d408f8b95391aea6d4066aecc9fde01dfe",
("amd64", 12, "lmtpd"): "dc3de473789969f7dd3504ac8783da5e42a446d2d7a305a4e9d7081a6dfe71ab",
("arm64", 12, "lmtpd"): "ae2cbd6c5c43f6d8e2172997b055448f4c79238e2f99cd9ab9200a7d9f548908",
("amd64", 13, "lmtpd"): "833b243e28c7baff141ecf37456e310f5d836e7944a3b9f2fe5074adf0d6a418",
("arm64", 13, "lmtpd"): "55af47a121ba7e23966b20ddaab2dff7feba4b34677864e045e31a702afa180d",
}
TURN_VERSION = "v0.4"
TURN_ARTIFACTS = {
"x86_64": (
f"https://github.com/chatmail/chatmail-turn/releases/download/{TURN_VERSION}/chatmail-turn-x86_64-linux",
"1ec1f5c50122165e858a5a91bcba9037a28aa8cb8b64b8db570aa457c6141a8a",
),
"aarch64": (
f"https://github.com/chatmail/chatmail-turn/releases/download/{TURN_VERSION}/chatmail-turn-aarch64-linux",
"0fb3e792419494e21ecad536464929dba706bb2c88884ed8f1788141d26fc756",
),
}
IROH_VERSION = "v0.35.0"
IROH_ARTIFACTS = {
"x86_64": (
f"https://github.com/n0-computer/iroh/releases/download/{IROH_VERSION}/iroh-relay-{IROH_VERSION}-x86_64-unknown-linux-musl.tar.gz",
"45c81199dbd70f8c4c30fef7f3b9727ca6e3cea8f2831333eeaf8aa71bf0fac1",
),
"aarch64": (
f"https://github.com/n0-computer/iroh/releases/download/{IROH_VERSION}/iroh-relay-{IROH_VERSION}-aarch64-unknown-linux-musl.tar.gz",
"f8ef27631fac213b3ef668d02acd5b3e215292746a3fc71d90c63115446008b1",
),
}
+26 -70
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
+50 -24
View File
@@ -53,15 +53,19 @@ smtpd_tls_exclude_ciphers = aNULL, RC4, MD5, DES
# See <https://www.postfix.org/FORWARD_SECRECY_README.html#server_fs>. # See <https://www.postfix.org/FORWARD_SECRECY_README.html#server_fs>.
tls_preempt_cipherlist = yes tls_preempt_cipherlist = yes
smtpd_relay_restrictions = permit_mynetworks permit_sasl_authenticated defer_unauth_destination # Reject by default, override per smtpd in master.cf
myhostname = {{ config.mail_domain }} smtpd_relay_restrictions = reject
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,26 +83,16 @@ 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
# Do not apply header checks to MIME headers
# and other headers that are actually part of the message body.
# Reference:
# <https://www.postfix.org/postconf.5.html#disable_mime_input_processing>
# <https://www.postfix.org/header_checks.5.html>
# <https://stump.io/blog/2020/11/29/a-little-gotcha-with-postfixs-header_checks/>
disable_mime_input_processing = yes
mua_client_restrictions = permit_sasl_authenticated, reject mua_client_restrictions = permit_sasl_authenticated, reject
mua_sender_restrictions = reject_sender_login_mismatch, permit_sasl_authenticated, reject mua_sender_restrictions = reject_sender_login_mismatch, permit_sasl_authenticated, reject
mua_helo_restrictions = permit_mynetworks, reject_invalid_helo_hostname, reject_non_fqdn_helo_hostname, permit mua_helo_restrictions = permit_mynetworks, reject_invalid_helo_hostname, reject_non_fqdn_helo_hostname, permit
@@ -109,3 +103,35 @@ 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 }}
# All deliveries over lmtp-filtermail are treated
# as having the same destination [127.0.0.1],
# so it is not possible to limit per-destination concurrency here,
# it is a job for filtermail-transport.
# Total number of parallel deliveries is limited
# by "maxproc" column in /etc/postfix/master.cf for lmtp-filtermail.
# Settings below are to prevent Postfix queue manager
# from limiting the number of LMTP connections to filtermail-transport.
# Read <https://www.postfix.org/TUNING_README.html#rope> and
# <https://www.postfix.org/SCHEDULER_README.html> for the details
# of the Postfix algorithm that we effectively disable here.
lmtp-filtermail_initial_destination_concurrency=10000
lmtp-filtermail_destination_concurrency_limit=10000
# Do not try to deliver messages for more than 2 days.
maximal_queue_lifetime = 2d
{% 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 %}
+22 -6
View File
@@ -17,7 +17,8 @@ smtp inet n - y - - smtpd
-o smtpd_tls_security_level=encrypt -o smtpd_tls_security_level=encrypt
-o smtpd_tls_mandatory_protocols=>=TLSv1.2 -o smtpd_tls_mandatory_protocols=>=TLSv1.2
-o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port_incoming }} -o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port_incoming }}
submission inet n - y - 5000 smtpd -o smtpd_relay_restrictions=reject_unauth_destination
submission inet n - y - {{ config.max_smtp_connections }} smtpd
-o syslog_name=postfix/submission -o syslog_name=postfix/submission
-o smtpd_tls_security_level=encrypt -o smtpd_tls_security_level=encrypt
-o smtpd_tls_mandatory_protocols=>=TLSv1.3 -o smtpd_tls_mandatory_protocols=>=TLSv1.3
@@ -31,9 +32,9 @@ submission inet n - y - 5000 smtpd
-o smtpd_sender_restrictions=$mua_sender_restrictions -o smtpd_sender_restrictions=$mua_sender_restrictions
-o smtpd_recipient_restrictions= -o smtpd_recipient_restrictions=
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject -o smtpd_relay_restrictions=permit_sasl_authenticated,reject
-o smtpd_client_connection_count_limit=1000 -o smtpd_client_connection_count_limit={{ config.max_smtp_connections // 5 }}
-o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port }} -o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port }}
smtps inet n - y - 5000 smtpd smtps inet n - y - {{ config.max_smtp_connections }} smtpd
-o syslog_name=postfix/smtps -o syslog_name=postfix/smtps
-o smtpd_tls_wrappermode=yes -o smtpd_tls_wrappermode=yes
-o smtpd_tls_security_level=encrypt -o smtpd_tls_security_level=encrypt
@@ -47,7 +48,7 @@ smtps inet n - y - 5000 smtpd
-o smtpd_sender_restrictions=$mua_sender_restrictions -o smtpd_sender_restrictions=$mua_sender_restrictions
-o smtpd_recipient_restrictions= -o smtpd_recipient_restrictions=
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject -o smtpd_relay_restrictions=permit_sasl_authenticated,reject
-o smtpd_client_connection_count_limit=1000 -o smtpd_client_connection_count_limit={{ config.max_smtp_connections // 5 }}
-o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port }} -o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port }}
#628 inet n - y - - qmqpd #628 inet n - y - - qmqpd
pickup unix n - y 60 1 pickup pickup unix n - y 60 1 pickup
@@ -75,17 +76,19 @@ lmtp unix - - y - - lmtp
anvil unix - - y - 1 anvil anvil unix - - y - 1 anvil
scache unix - - y - 1 scache scache unix - - y - 1 scache
postlog unix-dgram n - n - 1 postlogd postlog unix-dgram n - n - 1 postlogd
filter unix - n n - - lmtp
# Local SMTP server for reinjecting outgoing filtered mail. # Local SMTP server for reinjecting outgoing filtered mail.
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
-o smtpd_relay_restrictions=permit_mynetworks,reject
{% 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
-o syslog_name=postfix/reinject_incoming -o syslog_name=postfix/reinject_incoming
-o smtpd_relay_restrictions=reject_unauth_destination
# Cleanup `Received` headers for authenticated mail # Cleanup `Received` headers for authenticated mail
# to avoid leaking client IP. # to avoid leaking client IP.
@@ -100,3 +103,16 @@ 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
# Reducing `maxproc` here may result in a head of line blocking
# when there are many messages sent to unreachable destinations
# at the same time.
# LMTP clients here talk to filtermail-transport.
# LMTP has no pipelining,
# so while filtermail-transport tries to deliver the message,
# possibly waiting for a long connection timeout
# or talking to a slow server, LMTP client cannot be reused.
lmtp-filtermail unix - - y - 500 lmtp
-o syslog_name=postfix/lmtp-filtermail
-o lmtp_header_checks=
-o lmtp_tls_security_level=none
+12 -8
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(";")), "")
+5 -7
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
@@ -5,5 +5,5 @@ After=network.target
[Service] [Service]
Type=oneshot Type=oneshot
User=vmail User=vmail
ExecStart=/usr/local/lib/chatmaild/venv/bin/chatmail-expire /usr/local/lib/chatmaild/chatmail.ini -v --remove ExecStart={execpath} {config_path} -v --remove
@@ -5,5 +5,5 @@ After=network.target
[Service] [Service]
Type=oneshot Type=oneshot
User=vmail User=vmail
ExecStart=/usr/local/lib/chatmaild/venv/bin/chatmail-fsreport /usr/local/lib/chatmaild/chatmail.ini ExecStart={execpath} {config_path}
@@ -1,12 +1,11 @@
[Unit] [Unit]
Description=Chatmail dict authentication proxy for dovecot Description=Chatmail HTTP authentication service for dovecot
[Service] [Service]
ExecStart={execpath} /run/doveauth/doveauth.socket {config_path} ExecStart={execpath} {config_path}
Restart=always Restart=always
RestartSec=30 RestartSec=5
User=vmail User=vmail
RuntimeDirectory=doveauth
UMask=0077 UMask=0077
[Install] [Install]
@@ -5,7 +5,7 @@ After=network.target
[Service] [Service]
Type=simple Type=simple
Restart=always Restart=always
ExecStart=/usr/local/bin/chatmail-turn --realm {mail_domain} --socket /run/chatmail-turn/turn.socket ExecStart={bin_path} --realm {mail_domain} --socket /run/chatmail-turn/turn.socket
# Create /run/chatmail-turn # Create /run/chatmail-turn
RuntimeDirectory=chatmail-turn RuntimeDirectory=chatmail-turn
+27
View File
@@ -0,0 +1,27 @@
"""Run the lua scripts we ship under lupa, which bundles Lua 5.4 like dovecot."""
import pytest
from chatmaild.tests.plugin import * # noqa: F403
from lupa import lua54
from cmdeploy.basedeploy import get_resource
from cmdeploy.tests.plugin import * # noqa: F403
class Lua:
"""A Lua runtime to load shipped scripts and mocks into."""
def __init__(self):
self.rt = lua54.LuaRuntime(unpack_returned_tuples=True)
self.g = self.rt.globals()
def load(self, path):
self.rt.execute(get_resource(path).read_text())
def table(self, **kwargs):
return self.rt.table(**kwargs)
@pytest.fixture
def lua():
return Lua()
@@ -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}")
@@ -20,17 +20,17 @@ def test_fastcgi_working(maildomain, chatmail_config):
@pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning") @pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning")
def test_newemail_configure(maildomain, rpc, chatmail_config): def test_newemail_configure(maildomain, cmrpc, chatmail_config):
"""Test configuring accounts by scanning a QR code works.""" """Test configuring accounts by scanning a QR code works."""
url = f"DCACCOUNT:https://{maildomain}/new" url = f"DCACCOUNT:https://{maildomain}/new"
for i in range(3): for i in range(3):
account_id = rpc.add_account() account_id = cmrpc.add_account()
if chatmail_config.tls_cert_mode == "self": if chatmail_config.tls_cert_mode == "self":
# deltachat core's rustls rejects self-signed HTTPS certs during # deltachat core's rustls rejects self-signed HTTPS certs during
# set_config_from_qr, so fetch credentials via requests instead # set_config_from_qr, so fetch credentials via requests instead
res = requests.post(f"https://{maildomain}/new", verify=False) res = requests.post(f"https://{maildomain}/new", verify=False)
data = res.json() data = res.json()
rpc.add_or_update_transport(account_id, { cmrpc.add_or_update_transport(account_id, {
"addr": data["email"], "addr": data["email"],
"password": data["password"], "password": data["password"],
"imapServer": maildomain, "imapServer": maildomain,
@@ -38,4 +38,4 @@ def test_newemail_configure(maildomain, rpc, chatmail_config):
"certificateChecks": "acceptInvalidCertificates", "certificateChecks": "acceptInvalidCertificates",
}) })
else: else:
rpc.add_transport_from_qr(account_id, url) cmrpc.add_transport_from_qr(account_id, url)
@@ -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}"
)
@@ -1,10 +1,12 @@
import ipaddress import ipaddress
import json
import re import re
import time import time
import imap_tools import imap_tools
import pytest import pytest
import requests import requests
from chatmaild.tests.test_appversions import check_appversions
from cmdeploy.cmdeploy import get_sshexec from cmdeploy.cmdeploy import get_sshexec
from cmdeploy.remote import rshell from cmdeploy.remote import rshell
@@ -15,7 +17,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
@@ -51,6 +53,17 @@ class TestMetadataTokens:
assert res == b"1111 2222" assert res == b"1111 2222"
assert b"Getmetadata completed" in client.readline() assert b"Getmetadata completed" in client.readline()
def test_get_appversions(self, imap_mailbox):
"get app version information shipped with the relay"
client = imap_mailbox.client
client.send(b'a01 GETMETADATA "" /shared/vendor/deltachat/appversions\n')
res = client.readline()
assert res[:1] == b"*"
res = client.readline().strip().rstrip(b")")
# the served value is a single line and passes the shipped file's schema
check_appversions(json.loads(res))
assert b"Getmetadata completed" in client.readline()
class TestEndToEndDeltaChat: class TestEndToEndDeltaChat:
"Tests that use Delta Chat accounts on the chat mail instance." "Tests that use Delta Chat accounts on the chat mail instance."
@@ -178,7 +191,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)
+36 -34
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,24 +9,17 @@ 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
conftestdir = Path(__file__).parent
def _is_ip(domain): def format_mail_domain(raw_domain: str) -> str:
try: if is_valid_ipv4(raw_domain):
ipaddress.ip_address(domain) return f"[{raw_domain}]"
return True return raw_domain
except ValueError:
return False
def pytest_configure(config): def pytest_configure(config):
config._benchresults = {} config._benchresults = {}
config.addinivalue_line(
"markers", "slow: mark test to require --slow option to run"
)
def _get_chatmail_config(): def _get_chatmail_config():
@@ -58,7 +50,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")
@@ -213,7 +205,7 @@ class ImapConn:
status, res = self.conn.select() status, res = self.conn.select()
if int(res[0]) == 0: if int(res[0]) == 0:
raise ValueError("no messages in imap folder") raise ValueError("no messages in imap folder")
status, results = self.conn.fetch("1:*", "(RFC822)") status, results = self.conn.fetch("1:*", "(BODY.PEEK[])")
assert status == "OK" assert status == "OK"
return results return results
@@ -278,7 +270,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 +283,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 +308,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 +318,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,16 +333,17 @@ 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 = (
f"dclogin:{addr}" f"dclogin:{addr}"
f"?p={password}&v=1" f"?p={password}&v=1"
f"&ih={domain}&ip=993" f"&ih={domain}&ip=993&is=ssl"
f"&sh={domain}&sp=465" f"&sh={domain}&sp=465&ss=ssl"
f"&ic=3&ss=default" f"&ic=3"
) )
future = account.add_transport_from_qr.future(qr) future = account.add_transport_from_qr.future(qr)
else: else:
@@ -361,7 +354,7 @@ class ChatmailACFactory:
# ensure messages stay in INBOX so that they can be # ensure messages stay in INBOX so that they can be
# concurrently fetched via extra IMAP connections during tests # concurrently fetched via extra IMAP connections during tests
account.set_config("delete_server_after", "10") account.set_config("bcc_self", "1")
accounts.append(account) accounts.append(account)
for future in futures: for future in futures:
@@ -378,8 +371,12 @@ class ChatmailACFactory:
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def rpc(tmp_path_factory): def cmrpc(tmp_path_factory):
"""Start a deltachat-rpc-server process for the test session.""" """Start a deltachat-rpc-server process for the test session.
Not named "rpc": the deltachat-rpc-client pytest plugin registers a
function-scoped fixture under that name and would shadow this one.
"""
# NB: accounts_dir must NOT already exist as directory -- # NB: accounts_dir must NOT already exist as directory --
# core-rust only creates accounts.toml if the dir doesn't exist yet. # core-rust only creates accounts.toml if the dir doesn't exist yet.
@@ -391,10 +388,10 @@ def rpc(tmp_path_factory):
@pytest.fixture @pytest.fixture
def cmfactory(rpc, gencreds, maildomain, chatmail_config): def cmfactory(cmrpc, gencreds, maildomain, chatmail_config):
"""Return a ChatmailACFactory for creating online Delta Chat accounts.""" """Return a ChatmailACFactory for creating online Delta Chat accounts."""
return ChatmailACFactory( return ChatmailACFactory(
rpc=rpc, rpc=cmrpc,
maildomain=maildomain, maildomain=maildomain,
gencreds=gencreds, gencreds=gencreds,
chatmail_config=chatmail_config, chatmail_config=chatmail_config,
@@ -416,10 +413,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 +463,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 +478,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)
-2
View File
@@ -1,2 +0,0 @@
[pytest]
addopts = -vrsx --strict-markers
+216
View File
@@ -0,0 +1,216 @@
"""Test auth.lua script against mocked dovecot auth API."""
import jinja2
import pytest
from chatmaild.doveauth import encrypt_password, verify_password
from cmdeploy.basedeploy import get_resource
USER1 = "user12345@chat.example.org"
USER2 = "newuser12@chat.example.org"
OK, UNKNOWN, MISMATCH, INTERNAL = 1, -2, -3, -4
DOVECOT_MOCKS = """
create_status = 200
dovecot = {
auth = {
PASSDB_RESULT_OK = OK,
PASSDB_RESULT_USER_UNKNOWN = UNKNOWN,
PASSDB_RESULT_PASSWORD_MISMATCH = MISMATCH,
PASSDB_RESULT_INTERNAL_FAILURE = INTERNAL,
USERDB_RESULT_OK = OK,
USERDB_RESULT_USER_UNKNOWN = UNKNOWN,
},
http = {
client = function(options)
client_options = options
return {request = function(_, options)
create_request = options
return {
set_payload = function(_, payload) create_payload = payload end,
submit = function()
return {status = function() return create_status end}
end,
}
end}
end,
},
}
"""
def load_authlua(lua, config):
lua.g.OK, lua.g.UNKNOWN = OK, UNKNOWN
lua.g.MISMATCH, lua.g.INTERNAL = MISMATCH, INTERNAL
lua.rt.execute(DOVECOT_MOCKS)
template = jinja2.Template(get_resource("dovecot/auth.lua.j2").read_text())
lua.rt.execute(template.render(config=config))
assert lua.g.script_init() == 0
return lua
@pytest.fixture
def authlua(lua, example_config):
return load_authlua(lua, example_config)
@pytest.fixture
def request_for(lua):
def request_for(addr):
def password_verify(_self, hashed, plain):
return 1 if verify_password(hashed, plain) else 0
return lua.table(user=addr, password_verify=password_verify)
return request_for
@pytest.fixture
def create_user(example_config):
def create_user(addr, password):
example_config.get_user(addr).set_password(encrypt_password(password))
return create_user
@pytest.fixture
def write_password_file(example_config):
def write_password_file(addr, content):
maildir = example_config.mailboxes_dir / addr
maildir.mkdir(parents=True, exist_ok=True)
maildir.joinpath("password").write_text(content)
return write_password_file
def test_http_client_uses_dovecot_setting_names(authlua):
"""dovecot's lua http binding silently ignores keys it does not know."""
assert dict(authlua.g.client_options) == {
"request_timeout_msecs": 5000,
"max_attempts": 1,
}
def test_existing_address_correct_password(authlua, request_for, create_user):
create_user(USER1, "correctgoose")
res, fields = authlua.g.auth_password_verify(request_for(USER1), "correctgoose")
assert res == OK
assert fields["uid"] == fields["gid"] == "vmail"
assert fields["home"].endswith(USER1)
assert authlua.g.create_payload is None
def test_existing_address_wrong_password(authlua, request_for, create_user):
create_user(USER1, "correctgoose")
res, _ = authlua.g.auth_password_verify(request_for(USER1), "wronghorse")
assert res == MISMATCH
def test_foreign_domain_is_refused_without_calling_out(
authlua, request_for, create_user
):
create_user("user12345@evil.example.org", "correctgoose")
request = request_for("user12345@evil.example.org")
res, _ = authlua.g.auth_password_verify(request, "correctgoose")
assert res == UNKNOWN
assert authlua.g.auth_userdb_lookup(request)[0] == UNKNOWN
assert authlua.g.create_payload is None
def test_name_shorter_than_the_domain_is_refused(authlua, request_for):
for name in ("x", "", "chat.example.org"):
res, _ = authlua.g.auth_password_verify(request_for(name), "correctgoose")
assert res == UNKNOWN
assert authlua.g.auth_userdb_lookup(request_for(name))[0] == UNKNOWN
assert authlua.g.create_payload is None
def test_slash_in_username_is_refused(authlua, request_for):
request = request_for("../../etc/shadow@chat.example.org")
res, _ = authlua.g.auth_password_verify(request, "somepassword")
assert res == UNKNOWN
assert authlua.g.auth_userdb_lookup(request)[0] == UNKNOWN
assert authlua.g.create_payload is None
def test_localpart_policy_is_left_to_doveauth(authlua, request_for):
authlua.g.create_status = 403
res, _ = authlua.g.auth_password_verify(request_for("@chat.example.org"), "somepw")
assert res == UNKNOWN
assert authlua.g.create_payload == "@chat.example.org\tsomepw"
def test_unknown_address_is_created_via_endpoint(authlua, request_for):
res, fields = authlua.g.auth_password_verify(request_for(USER2), "brandnewpass")
assert res == OK
assert fields["home"].endswith(USER2)
assert authlua.g.create_payload == f"{USER2}\tbrandnewpass"
assert authlua.g.create_request["url"] == "http://127.0.0.1:10084/create"
# doveauth refusing is the user's problem, doveauth failing is ours
@pytest.mark.parametrize("status", [400, 403, 404])
def test_creation_refused_by_doveauth_is_user_unknown(authlua, request_for, status):
authlua.g.create_status = status
res, _ = authlua.g.auth_password_verify(request_for(USER2), "brandnewpass")
assert res == UNKNOWN
# 9003 is dovecot's own CONNECT_FAILED, what a stopped doveauth actually yields
@pytest.mark.parametrize("status", [500, 502, 9003, 9005])
def test_creation_that_doveauth_could_not_answer_is_internal_failure(
authlua, request_for, status
):
authlua.g.create_status = status
res, _ = authlua.g.auth_password_verify(request_for(USER2), "brandnewpass")
assert res == INTERNAL
def test_userdb_unknown_before_creation_ok_after(authlua, request_for, create_user):
request = request_for(USER1)
res, _ = authlua.g.auth_userdb_lookup(request)
assert res == UNKNOWN
# a userdb lookup must never create anything
assert authlua.g.create_payload is None
create_user(USER1, "correctgoose")
res, fields = authlua.g.auth_userdb_lookup(request)
assert res == OK
assert fields["home"].endswith(USER1)
assert fields["uid"] == fields["gid"] == "vmail"
def test_empty_password_file_is_unknown(authlua, request_for, write_password_file):
write_password_file(USER1, "")
assert authlua.g.auth_userdb_lookup(request_for(USER1))[0] == UNKNOWN
write_password_file(USER1, "\n")
assert authlua.g.auth_userdb_lookup(request_for(USER1))[0] == UNKNOWN
def test_password_file_format_checks(authlua, request_for, write_password_file):
write_password_file(USER1, encrypt_password("correctgoose") + "\n")
res, _ = authlua.g.auth_password_verify(request_for(USER1), "correctgoose")
assert res == OK
assert authlua.g.auth_userdb_lookup(request_for(USER1))[0] == OK
passhash = encrypt_password("correctgoose")
write_password_file(USER1, passhash + "\ntrailing junk")
authlua.g.create_status = 403
res, _ = authlua.g.auth_password_verify(request_for(USER1), "correctgoose")
assert res == UNKNOWN
assert authlua.g.auth_userdb_lookup(request_for(USER1))[0] == UNKNOWN
def test_ipv4_relay_uses_bracketed_domain(lua, ipv4_config, request_for):
# mail_domain is "[1.3.3.7]" here, and is_ours must not read it as a pattern
authlua = load_authlua(lua, ipv4_config)
addr = f"user12345@{ipv4_config.mail_domain}"
ipv4_config.get_user(addr).set_password(encrypt_password("correctgoose"))
res, fields = authlua.g.auth_password_verify(request_for(addr), "correctgoose")
assert res == OK
assert fields["home"].endswith(addr)
assert authlua.g.auth_userdb_lookup(request_for(addr))[0] == OK
assert authlua.g.auth_userdb_lookup(request_for(USER1))[0] == UNKNOWN
@@ -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
@@ -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()
+19 -3
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
@@ -3,30 +3,40 @@ from types import SimpleNamespace
import pytest import pytest
from pyinfra.facts.deb import DebPackages from pyinfra.facts.deb import DebPackages
from pyinfra.facts.server import Command
from cmdeploy.dovecot import deployer as dovecot_deployer from cmdeploy.dovecot import deployer as dovecot_deployer
def _fact_name(key):
if isinstance(key, tuple):
return f"{key[0].__name__}{key[1:]!r}"
return key.__name__
def make_host(*fact_pairs): def make_host(*fact_pairs):
"""Build a mock host; get_fact(cls) dispatches to the provided facts mapping. """Build a mock host; get_fact() dispatches to the provided facts mapping.
Args: Args:
*fact_pairs: tuples of (fact_class, fact_value) to register *fact_pairs: (fact_class, value) to match any call of that fact, or
((fact_class, *args), value) to match one specific call. Needed
for Command, which install() and check_restart() invoke with
different scripts; a bare Command entry would serve both.
Returns: Returns:
SimpleNamespace with get_fact that raises a clear error if an SimpleNamespace with get_fact that raises a clear error if an
unexpected fact type is requested. unregistered fact is requested.
""" """
facts = dict(fact_pairs) facts = dict(fact_pairs)
def get_fact(cls): def get_fact(cls, *args):
if cls not in facts: for key in ((cls, *args), cls):
registered = ", ".join(c.__name__ for c in facts) if key in facts:
raise LookupError( return facts[key]
f"unexpected get_fact({cls.__name__}); " registered = ", ".join(_fact_name(k) for k in facts)
f"only registered: {registered}" raise LookupError(
) f"unexpected get_fact({_fact_name((cls, *args))}); only registered: {registered}"
return facts[cls] )
return SimpleNamespace(get_fact=get_fact) return SimpleNamespace(get_fact=get_fact)
@@ -65,7 +75,9 @@ def track_shell(monkeypatch):
def test_download_dovecot_package_skips_epoch_matched_install(monkeypatch): def test_download_dovecot_package_skips_epoch_matched_install(monkeypatch):
epoch_version = dovecot_deployer.DOVECOT_PACKAGE_VERSION # what dpkg reports after installing our deb: epoch + the +debNu1 suffix
# that chatmail/dovecot CI stamps via dch before building
epoch_version = f"1:{dovecot_deployer._stamped_version(12)}"
downloads = [] downloads = []
monkeypatch.setattr( monkeypatch.setattr(
dovecot_deployer, dovecot_deployer,
@@ -83,15 +95,17 @@ def test_download_dovecot_package_skips_epoch_matched_install(monkeypatch):
lambda **kwargs: downloads.append(kwargs), lambda **kwargs: downloads.append(kwargs),
) )
deb, changed = dovecot_deployer._download_dovecot_package("core", "amd64") deb, changed = dovecot_deployer._download_dovecot_package("core", "amd64", deb_release=12)
assert deb is None, f"expected no deb path when version matches, got {deb!r}" assert deb is None, f"expected no deb path when version matches, got {deb!r}"
assert changed is False, "should not flag changed when version already installed" assert changed is False, "should not flag changed when version already installed"
assert downloads == [], "should not download when version already installed" assert downloads == [], "should not download when version already installed"
@pytest.mark.parametrize("deb_release", [12, 13])
@pytest.mark.parametrize("arch", ["amd64", "arm64"])
def test_download_dovecot_package_uses_archive_version_for_url_and_filename( def test_download_dovecot_package_uses_archive_version_for_url_and_filename(
monkeypatch, monkeypatch, deb_release, arch
): ):
downloads = [] downloads = []
monkeypatch.setattr( monkeypatch.setattr(
@@ -110,18 +124,26 @@ def test_download_dovecot_package_uses_archive_version_for_url_and_filename(
lambda **kwargs: downloads.append(kwargs), lambda **kwargs: downloads.append(kwargs),
) )
deb, changed = dovecot_deployer._download_dovecot_package("core", "amd64") deb, changed = dovecot_deployer._download_dovecot_package(
"core", arch, deb_release=deb_release
)
archive_version = dovecot_deployer.DOVECOT_ARCHIVE_VERSION.replace("+", "%2B") stamped = dovecot_deployer._stamped_version(deb_release)
expected_deb = f"/root/dovecot-core_{archive_version}_amd64.deb" expected_deb = f"/root/dovecot-core_{stamped}_{arch}.deb"
# Verify the returned path uses archive version, not package version (with epoch) # path uses the stamped version, and deb filenames never carry the epoch
assert changed is True, "should flag changed when package not yet installed" assert changed is True, "should flag changed when package not yet installed"
assert deb == expected_deb, f"deb path mismatch: {deb!r} != {expected_deb!r}" assert deb == expected_deb, f"deb path mismatch: {deb!r} != {expected_deb!r}"
assert dovecot_deployer.DOVECOT_PACKAGE_VERSION not in deb, ( assert "1:" not in deb, f"deb filename must not contain the epoch, got {deb!r}"
f"deb path should use archive version (no epoch), got {deb!r}"
)
assert len(downloads) == 1, "files.download should be called exactly once" assert len(downloads) == 1, "files.download should be called exactly once"
# the checksum is the security boundary: verify the right table row is used
assert (
downloads[0]["sha256sum"]
== dovecot_deployer.DOVECOT_SHA256[(arch, deb_release, "core")]
), "must pass the sha256 matching (arch, release, package)"
assert f"deb{deb_release}u1" in downloads[0]["src"], (
f"download URL should carry the deb{deb_release} suffix, got {downloads[0]['src']!r}"
)
def test_install_skips_dpkg_path_when_epoch_matched_packages_present( def test_install_skips_dpkg_path_when_epoch_matched_packages_present(
@@ -134,12 +156,14 @@ def test_install_skips_dpkg_path_when_epoch_matched_packages_present(
( (
dovecot_deployer.DebPackages, dovecot_deployer.DebPackages,
{ {
"dovecot-core": [dovecot_deployer.DOVECOT_PACKAGE_VERSION], "dovecot-core": [f"1:{dovecot_deployer._stamped_version(12)}"],
"dovecot-imapd": [dovecot_deployer.DOVECOT_PACKAGE_VERSION], "dovecot-imapd": [f"1:{dovecot_deployer._stamped_version(12)}"],
"dovecot-lmtpd": [dovecot_deployer.DOVECOT_PACKAGE_VERSION], "dovecot-lmtpd": [f"1:{dovecot_deployer._stamped_version(12)}"],
"dovecot-auth-lua": [f"1:{dovecot_deployer._stamped_version(12)}"],
}, },
), ),
(dovecot_deployer.Arch, "x86_64"), (dovecot_deployer.Arch, "x86_64"),
((Command, dovecot_deployer.VERSION_ID_CMD), 'VERSION_ID="12"'),
), ),
) )
downloads = [] downloads = []
@@ -153,41 +177,26 @@ def test_install_skips_dpkg_path_when_epoch_matched_packages_present(
assert downloads == [], "should not download when all packages epoch-matched" assert downloads == [], "should not download when all packages epoch-matched"
assert track_shell == [], "should not run dpkg when all packages epoch-matched" assert track_shell == [], "should not run dpkg when all packages epoch-matched"
assert deployer.need_restart is False, ( assert deployer.need_restart is False, "need_restart should be False when nothing changed"
"need_restart should be False when nothing changed"
)
def test_install_unsupported_arch_falls_back_to_apt( def test_install_unsupported_arch_raises(
deployer, patch_blocked, mock_files_put, track_shell, monkeypatch deployer, patch_blocked, mock_files_put, track_shell, monkeypatch
): ):
# For unsupported architectures, all fact lookups return the arch string.
monkeypatch.setattr( monkeypatch.setattr(
dovecot_deployer, dovecot_deployer,
"host", "host",
SimpleNamespace(get_fact=lambda cls: "riscv64"), make_host(
(dovecot_deployer.Arch, "riscv64"),
((Command, dovecot_deployer.VERSION_ID_CMD), 'VERSION_ID="12"'),
),
) )
apt_calls = []
# Mirrors apt.packages() return value: OperationMeta with .changed property. # we never fall back to the pinned distro package
# Only lmtpd triggers a change to verify |= accumulation of changed flags. with pytest.raises(ValueError, match="no dovecot build for dovecot-core"):
def fake_apt(**kwargs): deployer.install()
apt_calls.append(kwargs)
changed = "lmtpd" in kwargs["packages"][0]
return SimpleNamespace(changed=changed)
monkeypatch.setattr(dovecot_deployer.apt, "packages", fake_apt) assert track_shell == [], "should not run apt-get for unsupported arch"
deployer.install()
actual_pkgs = [c["packages"] for c in apt_calls]
assert actual_pkgs == [["dovecot-core"], ["dovecot-imapd"], ["dovecot-lmtpd"]], (
f"expected apt install of core/imapd/lmtpd, got {actual_pkgs}"
)
assert track_shell == [], "should not run dpkg for unsupported arch"
assert deployer.need_restart is True, (
"need_restart should be True when apt installed a package"
)
def test_install_runs_dpkg_when_packages_need_download( def test_install_runs_dpkg_when_packages_need_download(
@@ -199,6 +208,7 @@ def test_install_runs_dpkg_when_packages_need_download(
make_host( make_host(
(dovecot_deployer.DebPackages, {}), (dovecot_deployer.DebPackages, {}),
(dovecot_deployer.Arch, "x86_64"), (dovecot_deployer.Arch, "x86_64"),
((Command, dovecot_deployer.VERSION_ID_CMD), 'VERSION_ID="12"'),
), ),
) )
monkeypatch.setattr( monkeypatch.setattr(
@@ -214,17 +224,15 @@ def test_install_runs_dpkg_when_packages_need_download(
deployer.install() deployer.install()
assert len(track_shell) == 1, ( assert len(track_shell) == 1, f"expected one server.shell() call for dpkg install, got {len(track_shell)}"
f"expected one server.shell() call for dpkg install, got {len(track_shell)}"
)
cmds = track_shell[0]["commands"] cmds = track_shell[0]["commands"]
assert len(cmds) == 3, f"expected 3 dpkg/apt commands, got: {cmds}" assert len(cmds) == 1, f"expected single apt-get install command, got: {cmds}"
assert cmds[0].startswith("dpkg --force-confdef --force-confold -i ") assert "apt-get install -y" in cmds[0]
assert "apt-get -y --fix-broken install" in cmds[1] assert '-o Dpkg::Options::="--force-confdef"' in cmds[0]
assert cmds[2].startswith("dpkg --force-confdef --force-confold -i ") assert '-o Dpkg::Options::="--force-confold"' in cmds[0]
assert deployer.need_restart is True, ( assert "--allow-downgrades" in cmds[0]
"need_restart should be True after dpkg install" assert ".deb" in cmds[0]
) assert deployer.need_restart is True, "need_restart should be True after dpkg install"
def test_pick_url_falls_back_on_primary_error(monkeypatch): def test_pick_url_falls_back_on_primary_error(monkeypatch):
@@ -233,6 +241,45 @@ def test_pick_url_falls_back_on_primary_error(monkeypatch):
monkeypatch.setattr(dovecot_deployer.urllib.request, "urlopen", raise_error) monkeypatch.setattr(dovecot_deployer.urllib.request, "urlopen", raise_error)
result = dovecot_deployer._pick_url("http://primary", "http://fallback") result = dovecot_deployer._pick_url("http://primary", "http://fallback")
assert result == "http://fallback", ( assert result == "http://fallback", f"should fall back when primary fails, got {result!r}"
f"should fall back when primary fails, got {result!r}"
def test_install_fails_on_unsupported_debian_version(deployer, patch_blocked, monkeypatch):
monkeypatch.setattr(
dovecot_deployer,
"host",
make_host(
(dovecot_deployer.Arch, "x86_64"),
((Command, dovecot_deployer.VERSION_ID_CMD), 'VERSION_ID="99"'),
),
) )
with pytest.raises(ValueError, match="no dovecot build for dovecot-core on deb99"):
deployer.install()
@pytest.mark.parametrize(
"version_line", ["", None, "ID=debian"], ids=["empty", "none", "no-version-id"]
)
def test_parse_version_id_raises_without_version_id(version_line):
with pytest.raises(ValueError, match="cannot determine Debian release"):
dovecot_deployer._parse_version_id(version_line)
@pytest.mark.parametrize("deb_release", [12, 13])
def test_parse_version_id(deb_release):
parsed = dovecot_deployer._parse_version_id(f'VERSION_ID="{deb_release}"\n')
assert parsed == deb_release
def test_dovecot_sha256_covers_all_packages_per_release():
"""Every release in the table needs all four packages on both arches."""
table = dovecot_deployer.DOVECOT_SHA256
expected = {
(arch, pkg)
for arch in ("amd64", "arm64")
for pkg in ("core", "imapd", "lmtpd", "auth-lua")
}
for release in {r for _, r, _ in table}:
got = {(arch, pkg) for arch, r, pkg in table if r == release}
assert got == expected, f"deb{release} incomplete: {sorted(expected - got)}"
+2 -3
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")
@@ -0,0 +1,85 @@
"""Test the push_notification.lua we ship, against a mocked dovecot mail API."""
import textwrap
import pytest
USER1 = "user12345@chat.example.org"
USER2 = "user67890@chat.example.org"
DOVECOT_MOCKS = textwrap.dedent("""
function make_user(username)
local function mailbox(_, name)
record("mailbox " .. name)
return {
sync = function() record("sync") end,
metadata_set = function(_, k, v)
record("metadata_set " .. k .. "=" .. v)
end,
free = function() record("free") end,
}
end
return {username = username, mailbox = mailbox}
end
""")
@pytest.fixture
def script(lua):
lua.rt.execute(DOVECOT_MOCKS)
lua.load("dovecot/push_notification.lua")
return lua
@pytest.fixture
def deliver(script):
def deliver(recipient, sender):
calls = []
script.g.record = calls.append
user = script.g.make_user(recipient)
ctx = script.g.dovecot_lua_notify_begin_txn(user)
event = script.table(mailbox="INBOX", from_address=sender)
script.g.dovecot_lua_notify_event_message_new(ctx, event)
script.g.dovecot_lua_notify_end_txn(ctx, True)
return calls
return deliver
def test_entry_points_have_the_names_dovecot_calls(script):
assert script.g.dovecot_lua_notify_begin_txn is not None
assert script.g.dovecot_lua_notify_event_message_new is not None
assert script.g.dovecot_lua_notify_end_txn is not None
def test_begin_txn_returns_the_user_as_event_context(script):
user = script.g.make_user(USER1)
ctx = script.g.dovecot_lua_notify_begin_txn(user)
ctx.marker = "seen"
assert user.marker == "seen"
def test_incoming_message_notifies_metadata_server(deliver):
assert deliver(USER1, sender=USER2) == [
"mailbox INBOX",
"sync",
"metadata_set /private/messagenew=",
"free",
]
def test_own_message_does_not_wake_the_sending_device(deliver):
assert deliver(USER1, sender=USER1) == [
"mailbox INBOX",
"sync",
"free",
]
def test_message_without_from_address_is_notified(deliver):
assert deliver(USER1, sender=None) == [
"mailbox INBOX",
"sync",
"metadata_set /private/messagenew=",
"free",
]
@@ -1,4 +1,7 @@
# Managed by cmdeploy: disable IPv6 in unbound. # Managed by cmdeploy
server: server:
{% if disable_ipv6 %}
interface: 127.0.0.1 interface: 127.0.0.1
do-ip6: no do-ip6: no
{% endif %}
cache-max-negative-ttl: 0
+2 -4
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
+14
View File
@@ -3,6 +3,8 @@
# For the full list of built-in configuration values, see the documentation: # For the full list of built-in configuration values, see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html # https://www.sphinx-doc.org/en/master/usage/configuration.html
import os
# -- Project information ----------------------------------------------------- # -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
@@ -16,12 +18,24 @@ author = 'chatmail collective'
extensions = [ extensions = [
#'sphinx.ext.autodoc', #'sphinx.ext.autodoc',
#'sphinx.ext.viewdoc', #'sphinx.ext.viewdoc',
'sphinx.ext.extlinks',
'sphinxcontrib.mermaid', 'sphinxcontrib.mermaid',
] ]
templates_path = ['_templates'] templates_path = ['_templates']
exclude_patterns = [] exclude_patterns = []
# Repository links go through the roles below.
# CI sets DOC_GITHUB_REF to the head commit of a pull request,
gh_ref = os.environ.get("DOC_GITHUB_REF", "main")
extlinks = {
"repofile": (f"https://github.com/chatmail/relay/blob/{gh_ref}/%s", "%s"),
"repodir": (f"https://github.com/chatmail/relay/tree/{gh_ref}/%s", "%s"),
}
# Warn about repository links spelled out in full instead of using the roles.
extlinks_detect_hardcoded_links = True
# -- Options for HTML output ------------------------------------------------- # -- Options for HTML output -------------------------------------------------
+24
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,29 @@ 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/initenv.sh
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?
---------------------------------- ----------------------------------
+101 -2
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
---------------------- ----------------------
@@ -187,6 +199,93 @@ creating addresses, login with ssh to the deployment machine and run:
Chatmail address creation will be denied while this file is present. Chatmail address creation will be denied while this file is present.
.. _system-limits:
Configurable System Limits
--------------------------
Limits for auto-rejecting address creation
..........................................
A relay refuses creation of new addresses
when the machine runs low on resources,
but existing addresses keep working.
Three ``chatmail.ini`` settings control this,
shown here with their defaults::
max_load_1m = 5
min_available_memory = 200M
min_free_disk_space = 1G
- ``max_load_1m`` is the maximum 1-minute load average,
as reported by ``uptime``;
it counts processes waiting for disk I/O as well as for CPU.
It is deliberately not scaled by the number of CPUs
because I/O rather than CPU is what typically limits a relay.
- ``min_available_memory`` is the minimum memory available without swapping.
- ``min_free_disk_space`` is the minimum free disk space
on the file system holding the mailboxes.
The defaults suit the small machine described in
`Minimal requirements and prerequisites`_.
.. note::
If you run a bigger machine,
raise ``max_load_1m`` after watching ``uptime`` under typical load.
Rejections are logged by the ``doveauth`` service,
so you can check whether a limit is set too tightly::
journalctl -u doveauth --grep 'registration rejected'
If ``mtail_address`` is set, rejections are also counted
in the ``rejected_registrations`` metric.
Overall IMAP and SMTP connection limits
.......................................
Two further settings bound how many connections
the relay accepts at all, again shown with their defaults::
max_imap_connections = 10000
max_smtp_connections = 1000
``max_imap_connections`` becomes the Dovecot imap process limit,
and ``max_smtp_connections`` the Postfix process limit
on each of the submission and smtps ports.
A single client IP may use up to a fifth of ``max_smtp_connections``.
Each connection costs memory,
so these limits defend the relay against running out of RAM.
Unless ``imap_compress`` is enabled,
an IMAP connection that is idle for ``imap_hibernate_timeout``
is handed over to the ``imap-hibernate`` process
and does not count towards ``max_imap_connections``,
which is why a relay can serve far more IMAP clients
than this setting suggests.
If you run a large relay with 10k or 100k's of addresses,
check current connection counts before upgrading
and set the limits accordingly.
To see how close a running relay is to these two limits,
copy ``scripts/check-connections.sh`` from the relay repository
onto the relay and run it there::
imap 5 ports 143,993 (max_imap_connections)
5 dovecot sessions, 0 of them in an active imap process
submission 0 ports 465,587 (max_smtp_connections per port)
incoming 0 port 25 (from other relays, no chatmail.ini limit)
It counts established sockets with ``ss``
and cross-checks the IMAP number against ``doveadm who``.
Running a relay with self-signed certificates Running a relay with self-signed certificates
---------------------------------------------- ----------------------------------------------
+1
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
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`.
+91 -39
View File
@@ -6,13 +6,13 @@ Technical overview
Directories of the relay repository Directories of the relay repository
----------------------------------- -----------------------------------
The `chatmail relay repository <https://github.com/chatmail/relay/tree/main/>`_ The `chatmail relay repository <https://github.com/chatmail/relay>`_
has four main directories. has four main directories.
``scripts/`` ``scripts/``
~~~~~~~~~~~~~ ~~~~~~~~~~~~~
`scripts <https://github.com/chatmail/relay/tree/main/scripts>`_ :repodir:`scripts`
offers two convenience tools for beginners: offers two convenience tools for beginners:
- ``initenv.sh`` installs a local virtualenv Python environment and - ``initenv.sh`` installs a local virtualenv Python environment and
@@ -71,7 +71,7 @@ The deployed system components of a chatmail relay are:
``chatmaild/`` ``chatmaild/``
~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~
`chatmaild <https://github.com/chatmail/relay/tree/main/chatmaild>`_ :repodir:`chatmaild`
is a Python package containing several small services which handle is a Python package containing several small services which handle
authentication, trigger push notifications on new messages, ensure authentication, trigger push notifications on new messages, ensure
that outbound mails are encrypted, delete inactive users, and some that outbound mails are encrypted, delete inactive users, and some
@@ -83,25 +83,25 @@ that integrate with Dovecot and Postfix to achieve instant-onboarding
and only relaying OpenPGP end-to-end messages encrypted messages. A and only relaying OpenPGP end-to-end messages encrypted messages. A
short overview of ``chatmaild`` services: short overview of ``chatmaild`` services:
- `doveauth <https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/doveauth.py>`_ - :repofile:`doveauth <chatmaild/src/chatmaild/doveauth.py>`
implements create-on-login address semantics and is used by Dovecot implements create-on-login address semantics.
during IMAP login and by Postfix during SMTP/SUBMISSION login which Dovecot authenticates IMAP logins, and Postfix SMTP/SUBMISSION logins through `Dovecot SASL
in turn uses `Dovecot SASL <https://doc.dovecot.org/2.3/configuration_manual/authentication/authentication_mechanisms/>`_,
<https://doc.dovecot.org/2.3/configuration_manual/authentication/dict/#complete-example-for-authenticating-via-a-unix-socket>`_ from an :repofile:`auth.lua <cmdeploy/src/cmdeploy/dovecot/auth.lua.j2>` script
to authenticate logins. that reads the maildir directly. Only addresses which do not exist yet
are passed on to doveauth, which owns the creation policy.
- `chatmail-metadata <https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/metadata.py>`_ - :repofile:`chatmail-metadata <chatmaild/src/chatmaild/metadata.py>`
is contacted by a `Dovecot lua is contacted by a
script <https://github.com/chatmail/relay/blob/main/cmdeploy/src/cmdeploy/dovecot/push_notification.lua>`_ :repofile:`Dovecot lua script <cmdeploy/src/cmdeploy/dovecot/push_notification.lua>`
to store user-specific relay-side config. On new messages, it `passes to store user-specific relay-side config. On new messages, it
the users push notification :repofile:`passes the users push notification token <chatmaild/src/chatmaild/notifier.py>`
token <https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/notifier.py>`_
to to
`notifications.delta.chat <https://delta.chat/en/help#instant-delivery>`_ `notifications.delta.chat <https://delta.chat/en/help#instant-delivery>`_
so the push notifications on the users phone can be triggered by so the push notifications on the users phone can be triggered by
Apple/Google/Huawei. Apple/Google/Huawei.
- `chatmail-expire <https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/expire.py>`_ - :repofile:`chatmail-expire <chatmaild/src/chatmaild/expire.py>`
deletes old messages, large messages, and entire mailboxes deletes old messages, large messages, and entire mailboxes
of users who have not logged in for longer than of users who have not logged in for longer than
``delete_inactive_users_after`` days. ``delete_inactive_users_after`` days.
@@ -109,15 +109,14 @@ short overview of ``chatmaild`` services:
- ``chatmail-quota-expire`` is called by Dovecot's ``quota_warning`` mechanism - ``chatmail-quota-expire`` is called by Dovecot's ``quota_warning`` mechanism
and will automatically remove oldest messages to keep mailboxes well under ``max_mailbox_size``. and will automatically remove oldest messages to keep mailboxes well under ``max_mailbox_size``.
- `lastlogin <https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/lastlogin.py>`_ - :repofile:`lastlogin <chatmaild/src/chatmaild/lastlogin.py>`
is contacted by Dovecot when a user logs in and stores the date of is contacted by Dovecot when a user logs in and stores the date of
the login. the login.
``www/`` ``www/``
~~~~~~~~~ ~~~~~~~~~
`www <https://github.com/chatmail/relay/tree/main/www>`_ contains :repodir:`www` contains the html, css, and markdown files which make up a chatmail relays
the html, css, and markdown files which make up a chatmail relays
web page. Edit them before deploying to make your chatmail relay web page. Edit them before deploying to make your chatmail relay
stand out. stand out.
@@ -153,9 +152,11 @@ 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; postfix --- |milter opendkim.sock|OpenDKIM
dovecot --- |10084 create|doveauth;
dovecot --- |message delivery|maildir["maildir dovecot --- |message delivery|maildir["maildir
/home/vmail/.../user"]; /home/vmail/.../user"];
dovecot --- |lastlogin.socket|lastlogin; dovecot --- |lastlogin.socket|lastlogin;
@@ -178,26 +179,51 @@ Chatmail relay dependency diagram
style nginx-right fill:#f66; style nginx-right fill:#f66;
style postfix fill:#f66; style postfix fill:#f66;
style dovecot fill:#f66; style dovecot fill:#f66;
style OpenDKIM fill:#f66;
style notification-proxy fill:#f66; style notification-proxy fill:#f66;
Message between users on the same relay Accepting and delivering mail
--------------------------------------- -----------------------------
.. mermaid:: .. mermaid::
:caption: This diagram shows the path a non-federated message takes. :caption: This diagram shows all the paths a message can take.
graph LR; flowchart LR
sender --> |465|smtps/smtpd; subgraph chatmail relay
sender --> |587|submission/smtpd; subgraph postfix
smtps/smtpd --> |10080|filtermail; qmgr .-> lmtp-filtermail["lmtp/lmtp-filtermail (default_transport)"]
submission/smtpd --> |10080|filtermail; qmgr .-> lmtp["lmtp (local_transport)"]
filtermail --> |10025|smtpd_reinject; lmtp --> cleanup["cleanup (lmtp_header_cleanup)"]
smtpd_reinject --> cleanup; bounce
cleanup --> qmgr; smtpd-submission["smtpd/submission"]
qmgr --> smtpd_accepts_message; smtpd-smtps["smtpd/smtps"]
qmgr --> |lmtp|dovecot; smtpd-reinject-outgoing["smtpd/reinject-outgoing"] --> authclean["cleanup/authclean (submission_header_cleanup)"]
dovecot --> recipient; authclean --> qmgr
dovecot --> sender's_other_devices; smtpd-smtp["smtpd/smtp"]
smtpd-reinject-incoming["smtpd/reinject-incoming"] --> qmgr
end
lmtp-filtermail --LMTP inet:10083--> filtermail-transport
cleanup --LMTP unix:private/dovecot-lmtp --> dovecot
dovecot --> maildir
smtpd-submission --SMTP inet:10080--> filtermail-outgoing
smtpd-smtps --SMTP inet:10080--> filtermail-outgoing
filtermail-outgoing --SMTP inet:10025--> smtpd-reinject-outgoing
open-dkim["OpenDKIM (signing only)"] <--milter unix:opendkim/opendkim.sock--> smtpd-reinject-outgoing
bounce <--milter unix:opendkim/opendkim.sock--> open-dkim
bounce --> qmgr
nginx
smtpd-smtp -.SMTP inet:10081.-> filtermail-incoming
nginx -.HTTP inet:10082.-> filtermail-incoming
filtermail-incoming --SMTP inet:10026--> smtpd-reinject-incoming
end
filtermail-transport -.SMTP inet:25.-> mta1[Remote relay]
filtermail-transport -.HTTPS /mxdeliv.-> mta1
client[Client] -.SMTP inet:587.-> smtpd-submission
client -.SMTP inet:465.-> smtpd-smtps
client -.SMTP inet:443.-> nginx
nginx -.SMTP inet:465.-> smtpd-smtps
mta2[Remote relay] -.SMTP inet:25.-> smtpd-smtp
mta2 -.HTTPS /mxdeliv.-> nginx
Operational details of a chatmail relay Operational details of a chatmail relay
---------------------------------------- ----------------------------------------
@@ -222,6 +248,33 @@ Fresh chatmail addresses have a mailbox directory that contains:
directories will typically be empty unless the user of that address directories will typically be empty unless the user of that address
hasnt been online for a while. hasnt been online for a while.
App version information (experimental)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
A chatmail relay ships the
:repofile:`appversions.json <chatmaild/src/chatmaild/defaults/appversions.json>`
file of the ``chatmaild`` package
and serves its content under the IMAP METADATA key
``/shared/vendor/deltachat/appversions``.
Chat apps installed outside of app stores read this key
to learn about updates and where to download them.
The mechanism is experimental and may change.
The file travels with the normal deploy:
update the repository checkout and run ``cmdeploy run``.
Local modifications of ``appversions.json`` are deployed as-is,
so you can serve your own app version information,
including links to app downloads.
There is no automatic refresh:
version information changes only when you deploy again.
.. note::
Note that as of August 2026, only Delta Chat Android Google Play version
is beginning to support discovering app versions from relays.
Generally, consumers of relay-provided app version information
need to verify themselves that downloaded app files are valid.
Active ports Active ports
~~~~~~~~~~~~ ~~~~~~~~~~~~
@@ -264,7 +317,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 +349,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
+5 -4
View File
@@ -14,10 +14,11 @@ We know of three work-in-progress alternative implementation efforts:
it to support all of the features and configuration settings required it to support all of the features and configuration settings required
to operate as a chatmail relay. to operate as a chatmail relay.
- `Madmail <https://github.com/themadorg/madmail>`_: an - `Madmail <https://github.com/themadorg/madmail>`_: a Rust-based
experimental fork of `Maddy Mail Server <https://maddy.email/>`_, modified single-binary chatmail relay. Madmail v2 is a rewrite of an earlier
for chatmail deployments. It provides a single binary solution experimental fork of `Maddy Mail Server <https://maddy.email/>`_.
for running a chatmail relay. It includes SMTP, IMAP, encryption enforcement, and real-time
services (TURN/Iroh), and runs on Linux and Windows.
- `Chatmail Cookbook <https://github.com/feld/chatmail-cookbook>`_: - `Chatmail Cookbook <https://github.com/feld/chatmail-cookbook>`_:
A Chef Cookbook implementing a relay server. The project follows the A Chef Cookbook implementing a relay server. The project follows the
+26
View File
@@ -0,0 +1,26 @@
#!/bin/bash
#
# Show current IMAP and SMTP connections on a chatmail relay,
# to compare against the max_imap_connections and max_smtp_connections
# settings in chatmail.ini. Run this on the relay itself.
set -e
# Count established TCP connections whose local port is one of the arguments.
established() {
filter=$(printf 'sport = :%s or ' "$@")
ss -Htn state established "( ${filter% or } )" | wc -l
}
# doveadm prints a header line and then one line per logged-in user,
# with that user's number of connections in the second column.
sessions=$(doveadm who | awk 'NR > 1 { n += $2 } END { print n + 0 }')
# Unless imap_compress is enabled, connections idle for
# imap_hibernate_timeout are handed over to the imap-hibernate
# process, so they cost no imap process while idle.
active=$(pgrep -x imap | wc -l)
printf 'imap %6d ports 143,993 (max_imap_connections)\n' "$(established 143 993)"
printf ' %6d dovecot sessions, %d of them in an active imap process\n' "$sessions" "$active"
printf 'submission %6d ports 465,587 (max_smtp_connections per port)\n' "$(established 465 587)"
printf 'incoming %6d port 25 (from other relays, no chatmail.ini limit)\n' "$(established 25)"
+4
View File
@@ -20,6 +20,10 @@ fi
python3 -m venv --upgrade-deps venv python3 -m venv --upgrade-deps venv
# an editable install puts src/ on sys.path, so a leftover egg-info there is
# found as a second distribution and keeps removed entry points registered
rm -rf chatmaild/src/*.egg-info cmdeploy/src/*.egg-info
venv/bin/pip install -e chatmaild venv/bin/pip install -e chatmaild
venv/bin/pip install -e cmdeploy venv/bin/pip install -e cmdeploy
venv/bin/pip install sphinx sphinxcontrib-mermaid sphinx-autobuild furo # for building the docs venv/bin/pip install sphinx sphinxcontrib-mermaid sphinx-autobuild furo # for building the docs